HTMX lazy load for user statistics card

main
Noah Petherbridge 2024-04-24 20:36:37 -07:00
parent e7f7f4d0d3
commit f4721d65da
9 changed files with 242 additions and 103 deletions

View File

@ -79,14 +79,9 @@ func Profile() http.HandlerFunc {
var isSelf = currentUser.ID == user.ID
// Banned or disabled? Only admin can view then.
if user.Status != models.UserStatusActive && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
// Is either one blocking?
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
// Give a Not Found page if we can not see this user.
if err := user.CanBeSeenBy(currentUser); err != nil {
log.Error("%s can not be seen by viewer %s: %s", user.Username, currentUser.Username, err)
templates.NotFoundPage(w, r)
return
}
@ -107,20 +102,14 @@ func Profile() http.HandlerFunc {
}
vars := map[string]interface{}{
"User": user,
"LikeMap": likeMap,
"IsFriend": isFriend,
"IsPrivate": isPrivate,
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
"NoteCount": models.CountNotesAboutUser(currentUser, user),
"FriendCount": models.CountFriends(user.ID),
"ForumThreadCount": models.CountThreadsByUser(user),
"ForumReplyCount": models.CountCommentsByUser(user, "threads"),
"PhotoCommentCount": models.CountCommentsByUser(user, "photos"),
"CommentsReceivedCount": models.CountCommentsReceived(user),
"LikesGivenCount": models.CountLikesGiven(user),
"LikesReceivedCount": models.CountLikesReceived(user),
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
"User": user,
"LikeMap": likeMap,
"IsFriend": isFriend,
"IsPrivate": isPrivate,
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
"NoteCount": models.CountNotesAboutUser(currentUser, user),
"FriendCount": models.CountFriends(user.ID),
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
// Details on who likes their profile page.
"LikeExample": likeExample,

View File

@ -0,0 +1,80 @@
package htmx
import (
"net/http"
"net/url"
"time"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/middleware"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Statistics and social activity on the user's profile page.
func UserProfileActivityCard() http.HandlerFunc {
tmpl := templates.MustLoadCustom("partials/htmx/profile_activity.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
username = r.FormValue("username")
)
if username == "" {
templates.NotFoundPage(w, r)
return
}
// Debug: use ?delay=true to force a slower response.
if r.FormValue("delay") != "" {
time.Sleep(1 * time.Second)
}
// Find this user.
user, err := models.FindUser(username)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "You must be signed in to view this page.")
templates.Redirect(w, "/login?next=/u/"+url.QueryEscape(r.URL.String()))
return
}
// Is the site under a Maintenance Mode restriction?
if middleware.MaintenanceMode(currentUser, w, r) {
return
}
// Inject relationship booleans for profile picture display.
models.SetUserRelationships(currentUser, []*models.User{user})
// Give a Not Found page if we can not see this user.
if err := user.CanBeSeenBy(currentUser); err != nil {
log.Error("%s can not be seen by viewer %s: %s", user.Username, currentUser.Username, err)
templates.NotFoundPage(w, r)
return
}
vars := map[string]interface{}{
"User": user,
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
"FriendCount": models.CountFriends(user.ID),
"ForumThreadCount": models.CountThreadsByUser(user),
"ForumReplyCount": models.CountCommentsByUser(user, "threads"),
"PhotoCommentCount": models.CountCommentsByUser(user, "photos"),
"CommentsReceivedCount": models.CountCommentsReceived(user),
"LikesGivenCount": models.CountLikesGiven(user),
"LikesReceivedCount": models.CountLikesReceived(user),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -226,6 +226,31 @@ func (u *User) IsShyFrom(other *User) bool {
return true
}
// CanBeSeenBy checks whether the user can be seen to exist by the viewer.
//
// An admin viewer can always see them, but a user may be hidden to others when they are
// blocking, disabled or banned.
//
// The user should always be given a Not Found page so they can't tell the user even
// exists. The returned error will include a specific reason, for debugging purposes.
func (u *User) CanBeSeenBy(viewer *User) error {
if viewer.IsAdmin {
return nil
}
// Banned or disabled? Only admin can view then.
if u.Status != UserStatusActive {
return fmt.Errorf("user status is %s", u.Status)
}
// Is either one blocking?
if IsBlocking(viewer.ID, u.ID) && !viewer.IsAdmin {
return fmt.Errorf("users block each other")
}
return nil
}
// UserSearch config.
type UserSearch struct {
Username string

View File

@ -14,6 +14,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/controller/comment"
"code.nonshy.com/nonshy/website/pkg/controller/forum"
"code.nonshy.com/nonshy/website/pkg/controller/friend"
"code.nonshy.com/nonshy/website/pkg/controller/htmx"
"code.nonshy.com/nonshy/website/pkg/controller/inbox"
"code.nonshy.com/nonshy/website/pkg/controller/index"
"code.nonshy.com/nonshy/website/pkg/controller/photo"
@ -116,6 +117,9 @@ func New() http.Handler {
mux.Handle("POST /v1/barertc/report", barertc.Report())
mux.Handle("POST /v1/barertc/profile", barertc.Profile())
// HTMX endpoints.
mux.Handle("GET /htmx/user/profile/activity", middleware.LoginRequired(htmx.UserProfileActivityCard()))
// Redirect endpoints.
mux.Handle("GET /go/comment", middleware.LoginRequired(comment.GoToComment()))

View File

@ -46,6 +46,28 @@ func LoadTemplate(filename string) (*Template, error) {
}, nil
}
// LoadCustom loads a bare template without the site theme and partial templates attached.
//
// The custom TempleFuncs and vars are still available (PrettyTitle, .CurrentUser, etc.)
func LoadCustom(filename string) (*Template, error) {
filepath := config.TemplatePath + "/" + filename
stat, err := os.Stat(filepath)
if err != nil {
return nil, fmt.Errorf("LoadTemplate(%s): %s", filename, err)
}
tmpl := template.New("page")
tmpl.Funcs(TemplateFuncs(nil))
tmpl.ParseFiles(filepath)
return &Template{
filename: filename,
filepath: filepath,
modified: stat.ModTime(),
tmpl: tmpl,
}, nil
}
// Must LoadTemplate or panic.
func Must(filename string) *Template {
tmpl, err := LoadTemplate(filename)
@ -55,6 +77,15 @@ func Must(filename string) *Template {
return tmpl
}
// Must LoadCustom or panic.
func MustLoadCustom(filename string) *Template {
tmpl, err := LoadCustom(filename)
if err != nil {
panic(err)
}
return tmpl
}
// Execute a loaded template. In debug mode, the template file may be reloaded
// from disk if the file on disk has been modified.
func (t *Template) Execute(w http.ResponseWriter, r *http.Request, vars map[string]interface{}) error {

1
web/static/js/htmx-1.9.12.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -451,88 +451,12 @@
</header>
<div class="card-content">
<strong class="has-text-info">Statistics</strong>
<table class="table is-fullwidth" style="font-size: small">
<tr>
<td width="50%">
<strong class="is-size-7">Photos shared:</label>
</td>
<td>
<a href="/u/{{.User.Username}}/photos" class="has-text-info">
<i class="fa fa-image mr-1"></i>
{{FormatNumberCommas .PhotoCount}}
</a>
</td>
</tr>
<tr>
<td>
<strong class="is-size-7">Forum threads written:</label>
</td>
<td>
<a href="/forum/search?username={{.User.Username}}&type=threads" class="has-text-info">
<i class="fa fa-comment mr-1"></i>
{{FormatNumberCommas .ForumThreadCount}}
</a>
</td>
</tr>
<tr>
<td>
<strong class="is-size-7">Forum comments:</label>
</td>
<td>
<a href="/forum/search?username={{.User.Username}}" class="has-text-info">
<i class="fa fa-comments mr-1"></i>
{{FormatNumberCommas .ForumReplyCount}}
</a>
</td>
</tr>
</table>
<!-- Lazy load the statistics card-->
<div hx-get="/htmx/user/profile/activity?username={{.User.Username}}&delay=1" hx-trigger="load">
<i class="fa fa-spinner fa-spin mr-1"></i> Loading...
</div>
<strong class="has-text-info">Social</strong>
<table class="table is-fullwidth" style="font-size: small">
<tr>
<td width="50%">
<strong class="is-size-7">Friends:</label>
</td>
<td>
<a href="/u/{{.User.Username}}/friends" class="has-text-info">
<i class="fa fa-user-group mr-1"></i> {{FormatNumberCommas .FriendCount}}
</a>
</td>
</tr>
<tr>
<td>
<strong class="is-size-7">Photo comments written:</label>
</td>
<td>
<i class="fa fa-comments mr-1"></i> {{FormatNumberCommas .PhotoCommentCount}}
</td>
</tr>
<tr>
<td>
<strong class="is-size-7">Photo comments received:</label>
</td>
<td>
<i class="fa fa-comments mr-1"></i> {{FormatNumberCommas .CommentsReceivedCount}}
</td>
</tr>
<tr>
<td>
<strong class="is-size-7">Likes given:</label>
</td>
<td>
<i class="fa fa-heart mr-1"></i> {{FormatNumberCommas .LikesGivenCount}}
</td>
</tr>
<tr>
<td>
<strong class="is-size-7">Likes received:</label>
</td>
<td>
<i class="fa fa-heart mr-1"></i> {{FormatNumberCommas .LikesReceivedCount}}
</td>
</tr>
</table>
</div>
</div>

View File

@ -383,6 +383,7 @@
<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/vue-3.2.45.js"></script>
<script type="text/javascript" src="/static/js/htmx-1.9.12.min.js"></script>
{{template "scripts" .}}
<!-- Likes modal -->

View File

@ -0,0 +1,84 @@
{{define "base"}}
<strong class="has-text-info">Statistics</strong>
<table class="table is-fullwidth" style="font-size: small">
<tr>
<td width="50%">
<strong class="is-size-7">Photos shared:</label>
</td>
<td>
<a href="/u/{{.User.Username}}/photos" class="has-text-info">
<i class="fa fa-image mr-1"></i>
{{FormatNumberCommas .PhotoCount}}
</a>
</td>
</tr>
<tr>
<td>
<strong class="is-size-7">Forum threads written:</label>
</td>
<td>
<a href="/forum/search?username={{.User.Username}}&type=threads" class="has-text-info">
<i class="fa fa-comment mr-1"></i>
{{FormatNumberCommas .ForumThreadCount}}
</a>
</td>
</tr>
<tr>
<td>
<strong class="is-size-7">Forum comments:</label>
</td>
<td>
<a href="/forum/search?username={{.User.Username}}" class="has-text-info">
<i class="fa fa-comments mr-1"></i>
{{FormatNumberCommas .ForumReplyCount}}
</a>
</td>
</tr>
</table>
<strong class="has-text-info">Social</strong>
<table class="table is-fullwidth" style="font-size: small">
<tr>
<td width="50%">
<strong class="is-size-7">Friends:</label>
</td>
<td>
<a href="/u/{{.User.Username}}/friends" class="has-text-info">
<i class="fa fa-user-group mr-1"></i> {{FormatNumberCommas .FriendCount}}
</a>
</td>
</tr>
<tr>
<td>
<strong class="is-size-7">Photo comments written:</label>
</td>
<td>
<i class="fa fa-comments mr-1"></i> {{FormatNumberCommas .PhotoCommentCount}}
</td>
</tr>
<tr>
<td>
<strong class="is-size-7">Photo comments received:</label>
</td>
<td>
<i class="fa fa-comments mr-1"></i> {{FormatNumberCommas .CommentsReceivedCount}}
</td>
</tr>
<tr>
<td>
<strong class="is-size-7">Likes given:</label>
</td>
<td>
<i class="fa fa-heart mr-1"></i> {{FormatNumberCommas .LikesGivenCount}}
</td>
</tr>
<tr>
<td>
<strong class="is-size-7">Likes received:</label>
</td>
<td>
<i class="fa fa-heart mr-1"></i> {{FormatNumberCommas .LikesReceivedCount}}
</td>
</tr>
</table>
{{end}}