Add nested posts UI (#5)
## Summary - Cut hunt, question, submission, voting, hiding, and profile flows over to unified posts - Render nested replies with permission-aware inline Reply/Edit controls and edited markers - Add post profile queries and the author index migration they depend on Co-authored-by: codegirl-007 <s.raide@gmail.com>
This commit was merged in pull request #5.
This commit is contained in:
+71
-103
@@ -3,7 +3,6 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -64,13 +63,12 @@ type huntPage struct {
|
||||
Label string
|
||||
IsToday bool
|
||||
IsYesterday bool
|
||||
Questions []store.RankedQuestion
|
||||
Posts []*store.Post
|
||||
}
|
||||
|
||||
type questionPage struct {
|
||||
page
|
||||
Question *store.RankedQuestion
|
||||
Answer *store.Answer
|
||||
Question *store.Post
|
||||
}
|
||||
|
||||
type submitPage struct {
|
||||
@@ -90,11 +88,19 @@ type authPage struct {
|
||||
}
|
||||
|
||||
type voteCtx struct {
|
||||
User *store.User
|
||||
CSRF string
|
||||
View string
|
||||
Date string
|
||||
Question store.RankedQuestion
|
||||
User *store.User
|
||||
CSRF string
|
||||
View string
|
||||
Date string
|
||||
Post *store.Post
|
||||
}
|
||||
|
||||
type threadPostCtx struct {
|
||||
User *store.User
|
||||
CSRF string
|
||||
Root *store.Post
|
||||
Post *store.Post
|
||||
Depth int
|
||||
}
|
||||
|
||||
func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) {
|
||||
@@ -105,12 +111,27 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
|
||||
cfg.Mail = mail.Nop{}
|
||||
}
|
||||
funcMap := template.FuncMap{
|
||||
"voteCtx": func(user *store.User, csrf, view, date string, q store.RankedQuestion) voteCtx {
|
||||
return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Question: q}
|
||||
"voteCtx": func(user *store.User, csrf, view, date string, post *store.Post) voteCtx {
|
||||
return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Post: post}
|
||||
},
|
||||
"postCtx": func(user *store.User, csrf string, root, post *store.Post, depth int) threadPostCtx {
|
||||
return threadPostCtx{User: user, CSRF: csrf, Root: root, Post: post, Depth: depth}
|
||||
},
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"rank": func(i int) int { return i + 1 },
|
||||
"isAdmin": func(u *store.User) bool { return u.Admin() },
|
||||
"canReply": canReplyToThread,
|
||||
"canEditPost": canEditPost,
|
||||
"postLabel": postLabel,
|
||||
"postDepth": postDepthClass,
|
||||
"isEdited": func(post *store.Post) bool { return post != nil && post.UpdatedAt != post.CreatedAt },
|
||||
"postTime": func(value string) string {
|
||||
t, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
return t.In(pacific.Loc).Format("Jan 2, 2006 · 3:04 PM")
|
||||
},
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"rank": func(i int) int { return i + 1 },
|
||||
"isAdmin": func(u *store.User) bool { return u.Admin() },
|
||||
"pacificLabel": pacific.Label,
|
||||
"locationTag": func(u *store.User) string {
|
||||
if u != nil {
|
||||
@@ -281,7 +302,7 @@ func (s *Server) renderHunt(w http.ResponseWriter, r *http.Request, date string)
|
||||
if u := currentUser(r); u != nil {
|
||||
viewer = u.ID
|
||||
}
|
||||
questions, err := s.store.ListHunt(r.Context(), date, viewer)
|
||||
posts, err := s.store.ListRootPosts(r.Context(), date, viewer)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load questions", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -297,7 +318,7 @@ func (s *Server) renderHunt(w http.ResponseWriter, r *http.Request, date string)
|
||||
Label: label,
|
||||
IsToday: pacific.IsToday(date),
|
||||
IsYesterday: pacific.IsYesterday(date),
|
||||
Questions: questions,
|
||||
Posts: postPointers(posts),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -341,17 +362,17 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if len(city) > 80 {
|
||||
city = truncateRunes(city, 80)
|
||||
}
|
||||
q := &store.RankedQuestion{
|
||||
post := &store.Post{
|
||||
AuthorID: u.ID,
|
||||
Title: title,
|
||||
Body: body,
|
||||
City: city,
|
||||
}
|
||||
if err := s.store.CreateQuestion(r.Context(), q); err != nil {
|
||||
if err := s.store.CreatePost(r.Context(), post); err != nil {
|
||||
http.Error(w, "could not save question", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/questions/"+url.PathEscape(q.ID), http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/questions/"+url.PathEscape(post.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -360,29 +381,14 @@ func (s *Server) handleQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
if u := currentUser(r); u != nil {
|
||||
viewer = u.ID
|
||||
}
|
||||
q, err := s.store.GetQuestion(r.Context(), id, viewer)
|
||||
if err != nil || (q.Hidden && !currentUser(r).Admin()) {
|
||||
post, err := s.store.GetPostThreadForViewer(r.Context(), id, viewer)
|
||||
if err != nil || (post.PostState == store.PostStateHidden && !currentUser(r).Admin()) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var ans *store.Answer
|
||||
if q.Answered {
|
||||
ans, err = s.store.GetAnswer(r.Context(), q.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("question %s marked answered but answer missing", q.ID)
|
||||
http.Error(w, "answer unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Printf("get answer %s: %v", q.ID, err)
|
||||
http.Error(w, "could not load answer", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.exec(w, "question", questionPage{
|
||||
page: s.basePage(r, q.Title),
|
||||
Question: q,
|
||||
Answer: ans,
|
||||
page: s.basePage(r, post.Title),
|
||||
Question: post,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -412,8 +418,8 @@ func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "invalid vote", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.store.Vote(r.Context(), u.ID, id, value); err != nil {
|
||||
if errors.Is(err, store.ErrHiddenOrMissing) {
|
||||
if err := s.store.VotePost(r.Context(), u.ID, id, value); err != nil {
|
||||
if errors.Is(err, store.ErrPostNotVotable) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
@@ -427,17 +433,17 @@ func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderLeaderboard(w, r, date)
|
||||
return
|
||||
}
|
||||
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
|
||||
post, err := s.store.GetPostThreadForViewer(r.Context(), id, u.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.exec(w, "vote", voteCtx{
|
||||
User: u,
|
||||
CSRF: s.sessions.GetString(r.Context(), "csrf"),
|
||||
View: "question",
|
||||
Date: q.HuntDate,
|
||||
Question: *q,
|
||||
User: u,
|
||||
CSRF: s.sessions.GetString(r.Context(), "csrf"),
|
||||
View: "question",
|
||||
Date: post.PostDate,
|
||||
Post: post,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -460,15 +466,15 @@ func (s *Server) renderLeaderboard(w http.ResponseWriter, r *http.Request, date
|
||||
if u := currentUser(r); u != nil {
|
||||
viewer = u.ID
|
||||
}
|
||||
questions, err := s.store.ListHunt(r.Context(), date, viewer)
|
||||
posts, err := s.store.ListRootPosts(r.Context(), date, viewer)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load questions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.exec(w, "leaderboard", huntPage{
|
||||
page: s.basePage(r, ""),
|
||||
Date: date,
|
||||
Questions: questions,
|
||||
page: s.basePage(r, ""),
|
||||
Date: date,
|
||||
Posts: postPointers(posts),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -490,65 +496,27 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
if len(body) > 12000 {
|
||||
body = truncateRunes(body, 12000)
|
||||
}
|
||||
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
|
||||
if err != nil {
|
||||
root, err := s.store.GetPost(r.Context(), id)
|
||||
if err != nil || root.ParentID != nil || root.PostState == store.PostStateHidden {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, priorErr := s.store.GetAnswer(r.Context(), id)
|
||||
wasNew := errors.Is(priorErr, sql.ErrNoRows)
|
||||
if priorErr != nil && !wasNew {
|
||||
http.Error(w, "could not load answer", http.StatusInternalServerError)
|
||||
return
|
||||
reply := &store.Post{
|
||||
ParentID: &root.ID,
|
||||
AuthorID: u.ID,
|
||||
Body: body,
|
||||
}
|
||||
ans := &store.Answer{
|
||||
QuestionID: id,
|
||||
AuthorID: u.ID,
|
||||
Body: body,
|
||||
}
|
||||
if err := s.store.UpsertAnswer(r.Context(), ans); err != nil {
|
||||
if err := s.store.CreatePost(r.Context(), reply); err != nil {
|
||||
http.Error(w, "could not save answer", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if wasNew {
|
||||
s.notifyQuestionAnswered(q, body, u.ID)
|
||||
}
|
||||
saved, err := s.store.GetAnswer(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load answer", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
location := "/questions/" + url.PathEscape(id) + "#post-" + url.PathEscape(reply.ID)
|
||||
if isHTMX(r) {
|
||||
s.exec(w, "answer", questionPage{page: s.basePage(r, ""), Answer: saved})
|
||||
w.Header().Set("HX-Redirect", location)
|
||||
w.WriteHeader(http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/questions/"+url.PathEscape(id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) notifyQuestionAnswered(q *store.RankedQuestion, answerBody, adminID string) {
|
||||
if q == nil || s.cfg.Mail == nil {
|
||||
return
|
||||
}
|
||||
author, err := s.store.UserByID(context.Background(), q.AuthorID)
|
||||
if err != nil || author == nil || author.Email == "" || author.ID == adminID {
|
||||
return
|
||||
}
|
||||
msg := mail.QuestionAnswered{
|
||||
ToEmail: author.Email,
|
||||
ToName: author.Name,
|
||||
QuestionID: q.ID,
|
||||
QuestionTitle: q.Title,
|
||||
AnswerBody: answerBody,
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := s.cfg.Mail.NotifyQuestionAnswered(ctx, msg); err != nil {
|
||||
log.Printf("notify answer %s: %v", q.ID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("notify answer %s: accepted", q.ID)
|
||||
}()
|
||||
http.Redirect(w, r, location, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -561,17 +529,17 @@ func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
|
||||
if err != nil {
|
||||
post, err := s.store.GetPost(r.Context(), id)
|
||||
if err != nil || post.ParentID != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := s.store.HideQuestion(r.Context(), id); err != nil {
|
||||
if err := s.store.SetRootPostState(r.Context(), id, store.PostStateHidden); err != nil {
|
||||
http.Error(w, "could not hide", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if isHTMX(r) && r.PostFormValue("view") == "list" {
|
||||
s.renderLeaderboard(w, r, q.HuntDate)
|
||||
s.renderLeaderboard(w, r, post.PostDate)
|
||||
return
|
||||
}
|
||||
if isHTMX(r) {
|
||||
|
||||
Reference in New Issue
Block a user