Comment system: add, edit, delete for guests and Admins
This commit is contained in:
parent
ab5430df26
commit
725437d06f
|
@ -7,6 +7,7 @@ import (
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/kirsle/blog/core/jsondb"
|
"github.com/kirsle/blog/core/jsondb"
|
||||||
|
"github.com/kirsle/blog/core/models/comments"
|
||||||
"github.com/kirsle/blog/core/models/posts"
|
"github.com/kirsle/blog/core/models/posts"
|
||||||
"github.com/kirsle/blog/core/models/settings"
|
"github.com/kirsle/blog/core/models/settings"
|
||||||
"github.com/kirsle/blog/core/models/users"
|
"github.com/kirsle/blog/core/models/users"
|
||||||
|
@ -25,9 +26,6 @@ type Blog struct {
|
||||||
|
|
||||||
DB *jsondb.DB
|
DB *jsondb.DB
|
||||||
|
|
||||||
// Helper singletone
|
|
||||||
Posts *PostHelper
|
|
||||||
|
|
||||||
// Web app objects.
|
// Web app objects.
|
||||||
n *negroni.Negroni // Negroni middleware manager
|
n *negroni.Negroni // Negroni middleware manager
|
||||||
r *mux.Router // Router
|
r *mux.Router // Router
|
||||||
|
@ -41,7 +39,6 @@ func New(documentRoot, userRoot string) *Blog {
|
||||||
UserRoot: userRoot,
|
UserRoot: userRoot,
|
||||||
DB: jsondb.New(filepath.Join(userRoot, ".private")),
|
DB: jsondb.New(filepath.Join(userRoot, ".private")),
|
||||||
}
|
}
|
||||||
blog.Posts = InitPostHelper(blog)
|
|
||||||
|
|
||||||
// Load the site config, or start with defaults if not found.
|
// Load the site config, or start with defaults if not found.
|
||||||
settings.DB = blog.DB
|
settings.DB = blog.DB
|
||||||
|
@ -57,6 +54,7 @@ func New(documentRoot, userRoot string) *Blog {
|
||||||
// Initialize the rest of the models.
|
// Initialize the rest of the models.
|
||||||
posts.DB = blog.DB
|
posts.DB = blog.DB
|
||||||
users.DB = blog.DB
|
users.DB = blog.DB
|
||||||
|
comments.DB = blog.DB
|
||||||
|
|
||||||
// Initialize the router.
|
// Initialize the router.
|
||||||
r := mux.NewRouter()
|
r := mux.NewRouter()
|
||||||
|
@ -65,6 +63,7 @@ func New(documentRoot, userRoot string) *Blog {
|
||||||
r.HandleFunc("/logout", blog.LogoutHandler)
|
r.HandleFunc("/logout", blog.LogoutHandler)
|
||||||
blog.AdminRoutes(r)
|
blog.AdminRoutes(r)
|
||||||
blog.BlogRoutes(r)
|
blog.BlogRoutes(r)
|
||||||
|
blog.CommentRoutes(r)
|
||||||
|
|
||||||
// GitHub Flavored Markdown CSS.
|
// GitHub Flavored Markdown CSS.
|
||||||
r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets)))
|
r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets)))
|
||||||
|
|
10
core/blog.go
10
core/blog.go
|
@ -91,7 +91,7 @@ func (b *Blog) PrivatePosts(w http.ResponseWriter, r *http.Request) {
|
||||||
// PartialIndex handles common logic for blog index views.
|
// PartialIndex handles common logic for blog index views.
|
||||||
func (b *Blog) PartialIndex(w http.ResponseWriter, r *http.Request,
|
func (b *Blog) PartialIndex(w http.ResponseWriter, r *http.Request,
|
||||||
tag, privacy string) {
|
tag, privacy string) {
|
||||||
v := NewVars(map[interface{}]interface{}{})
|
v := NewVars()
|
||||||
|
|
||||||
// Get the blog index.
|
// Get the blog index.
|
||||||
idx, _ := posts.GetIndex()
|
idx, _ := posts.GetIndex()
|
||||||
|
@ -317,13 +317,13 @@ func (b *Blog) RenderPost(p *posts.Post, indexView bool) template.HTML {
|
||||||
filepath, err := b.ResolvePath("blog/entry.partial")
|
filepath, err := b.ResolvePath("blog/entry.partial")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err.Error())
|
log.Error(err.Error())
|
||||||
return "[error: missing blog/entry.partial]"
|
return template.HTML("[error: missing blog/entry.partial]")
|
||||||
}
|
}
|
||||||
t := template.New("entry.partial.gohtml")
|
t := template.New("entry.partial.gohtml")
|
||||||
t, err = t.ParseFiles(filepath.Absolute)
|
t, err = t.ParseFiles(filepath.Absolute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to parse entry.partial: %s", err.Error())
|
log.Error("Failed to parse entry.partial: %s", err.Error())
|
||||||
return "[error parsing template in blog/entry.partial]"
|
return template.HTML("[error parsing template in blog/entry.partial]")
|
||||||
}
|
}
|
||||||
|
|
||||||
meta := PostMeta{
|
meta := PostMeta{
|
||||||
|
@ -337,7 +337,7 @@ func (b *Blog) RenderPost(p *posts.Post, indexView bool) template.HTML {
|
||||||
err = t.Execute(&output, meta)
|
err = t.Execute(&output, meta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err.Error())
|
log.Error(err.Error())
|
||||||
return "[error executing template in blog/entry.partial]"
|
return template.HTML("[error executing template in blog/entry.partial]")
|
||||||
}
|
}
|
||||||
|
|
||||||
return template.HTML(output.String())
|
return template.HTML(output.String())
|
||||||
|
@ -372,7 +372,7 @@ func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.FormValue("submit") {
|
switch r.FormValue("submit") {
|
||||||
case "preview":
|
case "preview":
|
||||||
if post.ContentType == string(MARKDOWN) {
|
if post.ContentType == string(MARKDOWN) {
|
||||||
v.Data["preview"] = template.HTML(b.RenderMarkdown(post.Body))
|
v.Data["preview"] = template.HTML(b.RenderTrustedMarkdown(post.Body))
|
||||||
} else {
|
} else {
|
||||||
v.Data["preview"] = template.HTML(post.Body)
|
v.Data["preview"] = template.HTML(post.Body)
|
||||||
}
|
}
|
||||||
|
|
301
core/comments.go
Normal file
301
core/comments.go
Normal file
|
@ -0,0 +1,301 @@
|
||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
"github.com/kirsle/blog/core/models/comments"
|
||||||
|
"github.com/kirsle/blog/core/models/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommentRoutes attaches the comment routes to the app.
|
||||||
|
func (b *Blog) CommentRoutes(r *mux.Router) {
|
||||||
|
r.HandleFunc("/comments", b.CommentHandler)
|
||||||
|
r.HandleFunc("/comments/edit", b.EditCommentHandler)
|
||||||
|
r.HandleFunc("/comments/delete", b.DeleteCommentHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentMeta is the template variables for comment threads.
|
||||||
|
type CommentMeta struct {
|
||||||
|
IsAuthenticated bool
|
||||||
|
ID string
|
||||||
|
OriginURL string // URL where original comment thread appeared
|
||||||
|
Subject string // email subject
|
||||||
|
Thread *comments.Thread
|
||||||
|
Authors map[int]*users.User
|
||||||
|
CSRF string
|
||||||
|
|
||||||
|
// Cached name and email of the user.
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
EditToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderComments renders a comment form partial and returns the HTML.
|
||||||
|
func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject string, ids ...string) template.HTML {
|
||||||
|
id := strings.Join(ids, "-")
|
||||||
|
|
||||||
|
// Load their cached name and email if they posted a comment before.
|
||||||
|
name, _ := session.Values["c.name"].(string)
|
||||||
|
email, _ := session.Values["c.email"].(string)
|
||||||
|
editToken, _ := session.Values["c.token"].(string)
|
||||||
|
|
||||||
|
// Check if the user is a logged-in admin, to make all comments editable.
|
||||||
|
var isAdmin bool
|
||||||
|
var isAuthenticated bool
|
||||||
|
if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn {
|
||||||
|
isAuthenticated = true
|
||||||
|
if userID, ok := session.Values["user-id"].(int); ok {
|
||||||
|
if user, err := users.Load(userID); err == nil {
|
||||||
|
isAdmin = user.Admin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread, err := comments.Load(id)
|
||||||
|
if err != nil {
|
||||||
|
thread = comments.New(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render all the comments in the thread.
|
||||||
|
userMap := map[int]*users.User{}
|
||||||
|
for _, c := range thread.Comments {
|
||||||
|
c.HTML = template.HTML(b.RenderMarkdown(c.Body))
|
||||||
|
c.ThreadID = thread.ID
|
||||||
|
c.OriginURL = url
|
||||||
|
c.CSRF = csrfToken
|
||||||
|
|
||||||
|
// Look up the author username.
|
||||||
|
if c.UserID > 0 {
|
||||||
|
log.Warn("Has USERID %d", c.UserID)
|
||||||
|
if _, ok := userMap[c.UserID]; !ok {
|
||||||
|
log.Warn("not in map")
|
||||||
|
if user, err := users.Load(c.UserID); err == nil {
|
||||||
|
userMap[c.UserID] = user
|
||||||
|
log.Warn("is now!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if user, ok := userMap[c.UserID]; ok {
|
||||||
|
c.Name = user.Name
|
||||||
|
c.Username = user.Username
|
||||||
|
c.Email = user.Email
|
||||||
|
c.LoadAvatar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is it editable?
|
||||||
|
if isAdmin || (len(c.EditToken) > 0 && c.EditToken == editToken) {
|
||||||
|
c.Editable = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%v\n", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the template snippet.
|
||||||
|
filepath, err := b.ResolvePath("comments/comments.partial")
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err.Error())
|
||||||
|
return template.HTML("[error: missing comments/comments.partial]")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the comment view partial.
|
||||||
|
entryPartial, err := b.ResolvePath("comments/entry.partial")
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err.Error())
|
||||||
|
return template.HTML("[error: missing comments/entry.partial]")
|
||||||
|
}
|
||||||
|
|
||||||
|
t := template.New("comments.partial.gohtml")
|
||||||
|
t, err = t.ParseFiles(entryPartial.Absolute, filepath.Absolute)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to parse comments.partial: %s", err.Error())
|
||||||
|
return template.HTML("[error parsing template in comments/comments.partial]")
|
||||||
|
}
|
||||||
|
|
||||||
|
v := CommentMeta{
|
||||||
|
ID: thread.ID,
|
||||||
|
OriginURL: url,
|
||||||
|
Subject: subject,
|
||||||
|
CSRF: csrfToken,
|
||||||
|
Thread: &thread,
|
||||||
|
IsAuthenticated: isAuthenticated,
|
||||||
|
Name: name,
|
||||||
|
Email: email,
|
||||||
|
EditToken: editToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
output := bytes.Buffer{}
|
||||||
|
err = t.Execute(&output, v)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err.Error())
|
||||||
|
return template.HTML("[error executing template in comments/comments.partial]")
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.HTML(output.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentHandler handles the /comments URI for previewing and posting.
|
||||||
|
func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
b.BadRequest(w, r, "That method is not allowed.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := NewVars()
|
||||||
|
currentUser, _ := b.CurrentUser(r)
|
||||||
|
editToken := b.GetEditToken(w, r)
|
||||||
|
submit := r.FormValue("submit")
|
||||||
|
|
||||||
|
// Load the comment data from the form.
|
||||||
|
c := &comments.Comment{}
|
||||||
|
c.ParseForm(r)
|
||||||
|
if c.ThreadID == "" {
|
||||||
|
b.FlashAndRedirect(w, r, "/", "No thread ID found in the comment form.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the thread.
|
||||||
|
t, err := comments.Load(c.ThreadID)
|
||||||
|
if err != nil {
|
||||||
|
t = comments.New(c.ThreadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin URL to redirect them to at the end.
|
||||||
|
origin := "/"
|
||||||
|
if c.OriginURL != "" {
|
||||||
|
origin = c.OriginURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are we editing a post?
|
||||||
|
if r.FormValue("editing") == "true" {
|
||||||
|
id := r.FormValue("id")
|
||||||
|
c, err = t.Find(id)
|
||||||
|
if err != nil {
|
||||||
|
b.FlashAndRedirect(w, r, "/", "That comment was not found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify they have the matching edit token. Admin users are allowed.
|
||||||
|
if c.EditToken != editToken && !currentUser.Admin {
|
||||||
|
b.FlashAndRedirect(w, r, origin, "You don't have permission to edit that comment.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the extra form data into the comment struct.
|
||||||
|
c.ParseForm(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are we deleting said post?
|
||||||
|
if submit == "confirm-delete" {
|
||||||
|
t.Delete(c.ID)
|
||||||
|
b.FlashAndRedirect(w, r, origin, "Comment deleted!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache their name and email in their session.
|
||||||
|
session := b.Session(r)
|
||||||
|
session.Values["c.name"] = c.Name
|
||||||
|
session.Values["c.email"] = c.Email
|
||||||
|
session.Save(r, w)
|
||||||
|
|
||||||
|
// Previewing, deleting, or posting?
|
||||||
|
switch submit {
|
||||||
|
case "preview", "delete":
|
||||||
|
if !c.Editing && currentUser.IsAuthenticated {
|
||||||
|
c.Name = currentUser.Name
|
||||||
|
c.Email = currentUser.Email
|
||||||
|
c.LoadAvatar()
|
||||||
|
}
|
||||||
|
c.HTML = template.HTML(b.RenderMarkdown(c.Body))
|
||||||
|
case "post":
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
v.Error = err
|
||||||
|
} else {
|
||||||
|
// Store our edit token, if we don't have one. For example, admins
|
||||||
|
// can edit others' comments but should not replace their edit token.
|
||||||
|
if c.EditToken == "" {
|
||||||
|
c.EditToken = editToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're logged in, tag our user ID with this post.
|
||||||
|
if !c.Editing && c.UserID == 0 && currentUser.IsAuthenticated {
|
||||||
|
c.UserID = currentUser.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Post(c)
|
||||||
|
b.FlashAndRedirect(w, r, c.OriginURL, "Comment posted!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
v.Data["Thread"] = t
|
||||||
|
v.Data["Comment"] = c
|
||||||
|
v.Data["Editing"] = c.Editing
|
||||||
|
v.Data["Deleting"] = submit == "delete"
|
||||||
|
|
||||||
|
b.RenderTemplate(w, r, "comments/index.gohtml", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditCommentHandler for editing comments.
|
||||||
|
func (b *Blog) EditCommentHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
threadID = r.URL.Query().Get("t")
|
||||||
|
deleteToken = r.URL.Query().Get("d")
|
||||||
|
originURL = r.URL.Query().Get("o")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Our edit token.
|
||||||
|
editToken := b.GetEditToken(w, r)
|
||||||
|
|
||||||
|
// Search for the comment.
|
||||||
|
thread, err := comments.Load(threadID)
|
||||||
|
if err != nil {
|
||||||
|
b.FlashAndRedirect(w, r, "/", "That comment thread was not found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
comment, err := thread.FindByDeleteToken(deleteToken)
|
||||||
|
if err != nil {
|
||||||
|
b.FlashAndRedirect(w, r, "/", "That comment was not found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// And can we edit it?
|
||||||
|
if comment.EditToken != editToken {
|
||||||
|
b.Forbidden(w, r, "Your edit token is not valid for that comment.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comment.ThreadID = thread.ID
|
||||||
|
comment.OriginURL = originURL
|
||||||
|
|
||||||
|
v := NewVars()
|
||||||
|
v.Data["Thread"] = thread
|
||||||
|
v.Data["Comment"] = comment
|
||||||
|
v.Data["Editing"] = true
|
||||||
|
|
||||||
|
b.RenderTemplate(w, r, "comments/index.gohtml", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCommentHandler for editing comments.
|
||||||
|
func (b *Blog) DeleteCommentHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEditToken gets or generates an edit token from the user's session, which
|
||||||
|
// allows a user to edit their comment for a short while after they post it.
|
||||||
|
func (b *Blog) GetEditToken(w http.ResponseWriter, r *http.Request) string {
|
||||||
|
session := b.Session(r)
|
||||||
|
if token, ok := session.Values["c.token"].(string); ok && len(token) > 0 {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
token := uuid.New().String()
|
||||||
|
session.Values["c.token"] = token
|
||||||
|
session.Save(r, w)
|
||||||
|
return token
|
||||||
|
}
|
|
@ -23,6 +23,5 @@ func (b *Blog) RenderMarkdown(input string) string {
|
||||||
// Markdown pages, not for user-submitted comments or things.
|
// Markdown pages, not for user-submitted comments or things.
|
||||||
func (b *Blog) RenderTrustedMarkdown(input string) string {
|
func (b *Blog) RenderTrustedMarkdown(input string) string {
|
||||||
html := github_flavored_markdown.Markdown([]byte(input))
|
html := github_flavored_markdown.Markdown([]byte(input))
|
||||||
log.Info("%s", html)
|
|
||||||
return string(html)
|
return string(html)
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,6 +73,7 @@ func (b *Blog) CurrentUser(r *http.Request) (*users.User, error) {
|
||||||
if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn {
|
if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn {
|
||||||
id := session.Values["user-id"].(int)
|
id := session.Values["user-id"].(int)
|
||||||
u, err := users.LoadReadonly(id)
|
u, err := users.LoadReadonly(id)
|
||||||
|
u.IsAuthenticated = true
|
||||||
return u, err
|
return u, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
228
core/models/comments/comments.go
Normal file
228
core/models/comments/comments.go
Normal file
|
@ -0,0 +1,228 @@
|
||||||
|
package comments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/kirsle/blog/core/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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread contains a thread of comments, for a blog post or otherwise.
|
||||||
|
type Thread struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Comments []*Comment `json:"comments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment contains the data for a single comment in a thread.
|
||||||
|
type Comment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID int `json:"userId,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
EditToken string `json:"editToken"`
|
||||||
|
DeleteToken string `json:"deleteToken"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
|
||||||
|
// Private form use only.
|
||||||
|
CSRF string `json:"-"`
|
||||||
|
Subscribe bool `json:"-"`
|
||||||
|
ThreadID string `json:"-"`
|
||||||
|
OriginURL string `json:"-"`
|
||||||
|
Subject string `json:"-"`
|
||||||
|
HTML template.HTML `json:"-"`
|
||||||
|
Trap1 string `json:"-"`
|
||||||
|
Trap2 string `json:"-"`
|
||||||
|
|
||||||
|
// Even privater fields.
|
||||||
|
Username string `json:"-"`
|
||||||
|
Editable bool `json:"-"`
|
||||||
|
Editing bool `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// New initializes a new comment thread.
|
||||||
|
func New(id string) Thread {
|
||||||
|
return Thread{
|
||||||
|
ID: id,
|
||||||
|
Comments: []*Comment{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load a comment thread.
|
||||||
|
func Load(id string) (Thread, error) {
|
||||||
|
t := Thread{}
|
||||||
|
err := DB.Get(fmt.Sprintf("comments/threads/%s", id), &t)
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post a comment to a thread.
|
||||||
|
func (t *Thread) Post(c *Comment) error {
|
||||||
|
// If it has an ID, update an existing comment.
|
||||||
|
if len(c.ID) > 0 {
|
||||||
|
idx := -1
|
||||||
|
for i, comment := range t.Comments {
|
||||||
|
if comment.ID == c.ID {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the comment by index.
|
||||||
|
if idx >= 0 && idx < len(t.Comments) {
|
||||||
|
t.Comments[idx] = c
|
||||||
|
DB.Commit(fmt.Sprintf("comments/threads/%s", t.ID), t)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign an ID.
|
||||||
|
if c.ID == "" {
|
||||||
|
c.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
if c.DeleteToken == "" {
|
||||||
|
c.DeleteToken = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Comments = append(t.Comments, c)
|
||||||
|
DB.Commit(fmt.Sprintf("comments/threads/%s", t.ID), t)
|
||||||
|
|
||||||
|
// TODO: handle subscriptions.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a comment by its ID.
|
||||||
|
func (t *Thread) Find(id string) (*Comment, error) {
|
||||||
|
for _, c := range t.Comments {
|
||||||
|
if c.ID == id {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("comment not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a comment by its ID.
|
||||||
|
func (t *Thread) Delete(id string) error {
|
||||||
|
keep := []*Comment{}
|
||||||
|
var found bool
|
||||||
|
for _, c := range t.Comments {
|
||||||
|
if c.ID != id {
|
||||||
|
keep = append(keep, c)
|
||||||
|
} else {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return errors.New("comment not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Comments = keep
|
||||||
|
DB.Commit(fmt.Sprintf("comments/threads/%s", t.ID), t)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByDeleteToken finds a comment by its deletion token.
|
||||||
|
func (t *Thread) FindByDeleteToken(token string) (*Comment, error) {
|
||||||
|
for _, c := range t.Comments {
|
||||||
|
fmt.Printf("%s <> %s\n", c.DeleteToken, token)
|
||||||
|
if c.DeleteToken == token {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("comment not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseForm populates a Comment from a form.
|
||||||
|
func (c *Comment) ParseForm(r *http.Request) {
|
||||||
|
// Helper function to set an attribute only if the
|
||||||
|
// attribute is currently empty.
|
||||||
|
define := func(target *string, value string) {
|
||||||
|
if value != "" {
|
||||||
|
log.Info("SET DEFINE: %s", value)
|
||||||
|
*target = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
define(&c.ThreadID, r.FormValue("thread"))
|
||||||
|
define(&c.OriginURL, r.FormValue("origin"))
|
||||||
|
define(&c.Subject, r.FormValue("subject"))
|
||||||
|
|
||||||
|
define(&c.Name, r.FormValue("name"))
|
||||||
|
define(&c.Email, r.FormValue("email"))
|
||||||
|
define(&c.Body, r.FormValue("body"))
|
||||||
|
c.Subscribe = r.FormValue("subscribe") == "true"
|
||||||
|
|
||||||
|
// When editing a post
|
||||||
|
c.Editing = r.FormValue("editing") == "true"
|
||||||
|
|
||||||
|
c.Trap1 = r.FormValue("url")
|
||||||
|
c.Trap2 = r.FormValue("comment")
|
||||||
|
|
||||||
|
// Default the timestamp values.
|
||||||
|
if c.Created.IsZero() {
|
||||||
|
c.Created = time.Now().UTC()
|
||||||
|
c.Updated = c.Created
|
||||||
|
} else {
|
||||||
|
c.Updated = time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.LoadAvatar()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAvatar calculates the user's avatar for the comment.
|
||||||
|
func (c *Comment) LoadAvatar() {
|
||||||
|
// MD5 hash the email address for Gravatar.
|
||||||
|
if _, err := mail.ParseAddress(c.Email); err == nil {
|
||||||
|
h := md5.New()
|
||||||
|
io.WriteString(h, c.Email)
|
||||||
|
hash := fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
c.Avatar = fmt.Sprintf(
|
||||||
|
"//www.gravatar.com/avatar/%s?s=96",
|
||||||
|
hash,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Default gravatar.
|
||||||
|
c.Avatar = "https://www.gravatar.com/avatar/00000000000000000000000000000000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks the comment's fields for validity.
|
||||||
|
func (c *Comment) Validate() error {
|
||||||
|
// Spambot trap fields.
|
||||||
|
if c.Trap1 != "http://" || c.Trap2 != "" {
|
||||||
|
return errors.New("find a human")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required metadata fields.
|
||||||
|
if len(c.ThreadID) == 0 {
|
||||||
|
return errors.New("you lost the comment thread ID")
|
||||||
|
} else if len(c.Subject) == 0 {
|
||||||
|
return errors.New("this comment thread is missing a subject")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.Body) == 0 {
|
||||||
|
return errors.New("the message is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -26,6 +26,8 @@ type User struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|
||||||
|
IsAuthenticated bool `json:"-"`
|
||||||
|
|
||||||
// Whether the user was loaded in read-only mode (no password), so they
|
// Whether the user was loaded in read-only mode (no password), so they
|
||||||
// can't be saved without a password.
|
// can't be saved without a password.
|
||||||
readonly bool
|
readonly bool
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
package core
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/kirsle/blog/core/jsondb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PostHelper is a singleton helper to manage the database controls for blog
|
|
||||||
// entries.
|
|
||||||
type PostHelper struct {
|
|
||||||
master *Blog
|
|
||||||
DB *jsondb.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitPostHelper initializes the blog post controller helper.
|
|
||||||
func InitPostHelper(master *Blog) *PostHelper {
|
|
||||||
return &PostHelper{
|
|
||||||
master: master,
|
|
||||||
DB: master.DB,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetIndex loads the blog index (cache).
|
|
||||||
func (p *PostHelper) GetIndex() {}
|
|
|
@ -98,16 +98,27 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The comment entry partial.
|
||||||
|
commentEntry, err := b.ResolvePath("comments/entry.partial")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("RenderTemplate(%s): comments/entry.partial not found")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Useful template functions.
|
// Useful template functions.
|
||||||
log.Error("HERE!!!")
|
|
||||||
t := template.New(filepath.Absolute).Funcs(template.FuncMap{
|
t := template.New(filepath.Absolute).Funcs(template.FuncMap{
|
||||||
"StringsJoin": strings.Join,
|
"StringsJoin": strings.Join,
|
||||||
"RenderPost": b.RenderPost,
|
"RenderPost": b.RenderPost,
|
||||||
|
"RenderComments": func(subject string, ids ...string) template.HTML {
|
||||||
|
session := b.Session(r)
|
||||||
|
csrf := b.GenerateCSRFToken(w, r, session)
|
||||||
|
return b.RenderComments(session, csrf, r.URL.Path, subject, ids...)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Parse the template files. The layout comes first because it's the wrapper
|
// Parse the template files. The layout comes first because it's the wrapper
|
||||||
// and allows the filepath template to set the page title.
|
// and allows the filepath template to set the page title.
|
||||||
t, err = t.ParseFiles(layout.Absolute, filepath.Absolute)
|
t, err = t.ParseFiles(layout.Absolute, commentEntry.Absolute, filepath.Absolute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err.Error())
|
log.Error(err.Error())
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -17,7 +17,8 @@
|
||||||
{{ if $p.EnableComments }}
|
{{ if $p.EnableComments }}
|
||||||
<h2 class="mt-4">Comments</h2>
|
<h2 class="mt-4">Comments</h2>
|
||||||
|
|
||||||
TBD.
|
{{ $idStr := printf "%d" $p.ID}}
|
||||||
|
{{ RenderComments $p.Title "post" $idStr }}
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<hr>
|
<hr>
|
||||||
<em>Comments are disabled on this post.</em>
|
<em>Comments are disabled on this post.</em>
|
||||||
|
|
97
root/comments/comments.partial.gohtml
Normal file
97
root/comments/comments.partial.gohtml
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
{{ $t := .Thread }}
|
||||||
|
{{ $a := .Authors }}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{- if eq (len $t.Comments) 1 -}}
|
||||||
|
There is 1 comment on this page.
|
||||||
|
{{- else -}}
|
||||||
|
There are {{ len $t.Comments }} comments on this page.
|
||||||
|
{{- end -}}
|
||||||
|
<a href="#add-comment">Add your comment.</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{ range $t.Comments }}
|
||||||
|
{{ template "comment" . }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<h3 id="add-comment">Add a Comment</h3>
|
||||||
|
|
||||||
|
<form action="/comments" method="POST">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
||||||
|
<input type="hidden" name="thread" value="{{ .Thread.ID }}">
|
||||||
|
<input type="hidden" name="subject" value="{{ .Subject }}">
|
||||||
|
<input type="hidden" name="origin" value="{{ .OriginURL }}">
|
||||||
|
|
||||||
|
{{ if not .IsAuthenticated }}
|
||||||
|
<div class="form-group row">
|
||||||
|
<label for="name" class="col-2 col-form-label">Your name:</label>
|
||||||
|
<div class="col-10">
|
||||||
|
<input type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
class="form-control"
|
||||||
|
value="{{ .Name }}"
|
||||||
|
placeholder="Anonymous">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group row">
|
||||||
|
<label for="email" class="col-2 col-form-label">Your email:</label>
|
||||||
|
<div class="col-10">
|
||||||
|
<input type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
class="form-control"
|
||||||
|
aria-describedby="emailHelp"
|
||||||
|
value="{{ .Email }}"
|
||||||
|
placeholder="(optional)">
|
||||||
|
<small id="emailHelp" class="form-text text-muted">
|
||||||
|
Used for your <a href="https://en.gravatar.com/" target="_blank">Gravatar</a>
|
||||||
|
and optional thread subscription. <a href="/privacy">Privacy policy.</a>
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<label class="form-check-label pl-0">
|
||||||
|
<input type="checkbox" name="subscribe" value="true">
|
||||||
|
<small>Notify me of future comments on this page.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="body">Message:</label>
|
||||||
|
<textarea
|
||||||
|
name="body"
|
||||||
|
id="body"
|
||||||
|
cols="40" rows="10"
|
||||||
|
aria-describedby="bodyHelp"
|
||||||
|
class="form-control"></textarea>
|
||||||
|
<small id="bodyHelp" class="form-text text-muted">
|
||||||
|
You may format your message using
|
||||||
|
<a href="https://daringfireball.net/projects/markdown/syntax">Markdown</a>
|
||||||
|
syntax.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="display: none">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Sanity Check</div>
|
||||||
|
<div class="card-body">
|
||||||
|
If you happen to be able to see these fields, do not change
|
||||||
|
their values.
|
||||||
|
|
||||||
|
<input type="text" name="url" value="http://" class="form-control" placeholder="Website">
|
||||||
|
<textarea name="comment" cols="80" rows="10" class="form-control" placeholder="Comment"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
name="submit"
|
||||||
|
value="preview"
|
||||||
|
class="btn btn-primary">Preview</button>
|
||||||
|
<button type="submit"
|
||||||
|
name="submit"
|
||||||
|
value="post"
|
||||||
|
class="btn btn-danger">Post</button>
|
||||||
|
</form>
|
53
root/comments/entry.partial.gohtml
Normal file
53
root/comments/entry.partial.gohtml
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
{{ define "comment" }}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-2">
|
||||||
|
<img src="{{ .Avatar }}"
|
||||||
|
width="96"
|
||||||
|
height="96"
|
||||||
|
alt="Avatar image">
|
||||||
|
</div>
|
||||||
|
<div class="col-10">
|
||||||
|
<div class="comment-meta">
|
||||||
|
{{ if and .UserID .Username }}
|
||||||
|
<a href="/u/{{ .Username }}"><strong>{{ or .Name "Anonymous" }}</strong></a>
|
||||||
|
{{ else }}
|
||||||
|
<strong>{{ or .Name "Anonymous" }}</strong>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
posted on {{ .Created.Format "January 2, 2006 @ 15:04 MST" }}
|
||||||
|
|
||||||
|
{{ if .Updated.After .Created }}
|
||||||
|
<span title="{{ .Updated.Format "Jan 2 2006 @ 15:04:05 MST" }}">
|
||||||
|
(updated {{ .Updated.Format "1/2/06 15:04 MST"}})
|
||||||
|
</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ .HTML }}
|
||||||
|
|
||||||
|
{{ if .Editable }}
|
||||||
|
<form action="/comments" method="POST">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
||||||
|
<input type="hidden" name="id" value="{{ .ID }}">
|
||||||
|
<input type="hidden" name="thread" value="{{ .ThreadID }}">
|
||||||
|
<input type="hidden" name="subject" value="(editing)">
|
||||||
|
<input type="hidden" name="origin" value="{{ .OriginURL }}">
|
||||||
|
<input type="hidden" name="editing" value="true">
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
name="submit"
|
||||||
|
value="preview"
|
||||||
|
class="btn btn-sm btn-primary">edit</button>
|
||||||
|
<button type="submit"
|
||||||
|
name="submit"
|
||||||
|
value="delete"
|
||||||
|
class="btn btn-sm btn-danger">delete</button>
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
122
root/comments/index.gohtml
Normal file
122
root/comments/index.gohtml
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
{{ define "title" }}Preview Comment{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
|
||||||
|
{{ with .Data.Comment }}
|
||||||
|
<form action="/comments" method="POST">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ $.CSRF }}">
|
||||||
|
<input type="hidden" name="thread" value="{{ .ThreadID }}">
|
||||||
|
<input type="hidden" name="subject" value="{{ .Subject }}">
|
||||||
|
<input type="hidden" name="origin" value="{{ .OriginURL }}">
|
||||||
|
{{ if $.Data.Editing -}}
|
||||||
|
<input type="hidden" name="id" value="{{ .ID }}">
|
||||||
|
<input type="hidden" name="editing" value="{{ $.Data.Editing }}">
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
{{- if $.Data.Deleting -}}
|
||||||
|
Delete Comment
|
||||||
|
{{- else if $.Data.Editing -}}
|
||||||
|
Edit Comment
|
||||||
|
{{- else -}}
|
||||||
|
Preview
|
||||||
|
{{- end -}}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
{{ template "comment" . }}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
{{ if $.Data.Deleting }}
|
||||||
|
<p>Are you sure you want to delete this comment?</p>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
name="submit"
|
||||||
|
value="confirm-delete"
|
||||||
|
class="btn btn-danger">
|
||||||
|
Delete Comment
|
||||||
|
</button>
|
||||||
|
<a href="{{ .OriginURL }}" class="btn btn-primary">Cancel</a>
|
||||||
|
{{ else }}
|
||||||
|
{{ if not $.CurrentUser.IsAuthenticated }}
|
||||||
|
<div class="form-group row">
|
||||||
|
<label for="name" class="col-2 col-form-label">Your name:</label>
|
||||||
|
<div class="col-10">
|
||||||
|
{{ if and $.CurrentUser.IsAuthenticated }}
|
||||||
|
{{ $.CurrentUser.Name }}
|
||||||
|
{{ else }}
|
||||||
|
<input type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
class="form-control"
|
||||||
|
value="{{ .Name }}"
|
||||||
|
placeholder="Anonymous">
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group row">
|
||||||
|
<label for="email" class="col-2 col-form-label">Your email:</label>
|
||||||
|
<div class="col-10">
|
||||||
|
<input type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
class="form-control"
|
||||||
|
aria-describedby="emailHelp"
|
||||||
|
value="{{ .Email }}"
|
||||||
|
placeholder="(optional)">
|
||||||
|
<small id="emailHelp" class="form-text text-muted">
|
||||||
|
Used for your <a href="https://en.gravatar.com/" target="_blank">Gravatar</a>
|
||||||
|
and optional thread subscription. <a href="/privacy">Privacy policy.</a>
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<label class="form-check-label pl-0">
|
||||||
|
<input type="checkbox"{{ if .Subscribe }} checked{{ end }}
|
||||||
|
name="subscribe"
|
||||||
|
value="true">
|
||||||
|
<small>Notify me of future comments on this page.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="body">Message:</label>
|
||||||
|
<textarea
|
||||||
|
name="body"
|
||||||
|
id="body"
|
||||||
|
cols="40" rows="10"
|
||||||
|
aria-describedby="bodyHelp"
|
||||||
|
class="form-control">{{ .Body }}</textarea>
|
||||||
|
<small id="bodyHelp" class="form-text text-muted">
|
||||||
|
You may format your message using
|
||||||
|
<a href="https://daringfireball.net/projects/markdown/syntax">Markdown</a>
|
||||||
|
syntax.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="display: none">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Sanity Check</div>
|
||||||
|
<div class="card-body">
|
||||||
|
If you happen to be able to see these fields, do not change
|
||||||
|
their values.
|
||||||
|
|
||||||
|
<input type="text" name="url" value="http://" class="form-control" placeholder="Website">
|
||||||
|
<textarea name="comment" cols="80" rows="10" class="form-control" placeholder="Comment"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" name="submit" value="preview" class="btn btn-primary">
|
||||||
|
Refresh Preview
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="submit" value="post" class="btn btn-secondary">
|
||||||
|
Post Comment
|
||||||
|
</button>
|
||||||
|
{{ end }}
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ end }}
|
|
@ -23,6 +23,13 @@ a.blog-title {
|
||||||
color: #909;
|
color: #909;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Comment metadata line */
|
||||||
|
.comment-meta {
|
||||||
|
font-style: italic;
|
||||||
|
font-size: smaller;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Code blocks treated as <pre> tags */
|
/* Code blocks treated as <pre> tags */
|
||||||
.markdown code {
|
.markdown code {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
Loading…
Reference in New Issue
Block a user