Finish Forums + Likes & Notifications

Finish implementing the basic forum features:
* Pinned threads (admin or board owner only)
* Edit Thread settings when you edit the top-most comment.
* NoReply threads remove all the reply buttons.
* Explicit forums and threads are filtered out unless opted-in (admins
  always see them).
* Count the unique members who participated in each forum.
* Get the most recently updated thread to show on forum list page.
* Contact/Report page: handle receiving a comment ID to report on.

Implement Likes & Notifications
* Like buttons added to Photos and Profile Pages. Implemented via simple
  vanilla JS (likes.js) to make ajax requests to back-end to like/unlike.
* Notifications: for your photo or profile being liked. If you unlike,
  the existing notifications about the like are revoked.
* The notifications appear as an alert number in the nav bar and are read
  on the User Dashboard. Click to mark a notification as "read" or click
  the "mark all as read" button.

Update DeleteUser to scrub likes, notifications, threads, and comments.
forums
Noah 2022-08-24 21:17:34 -07:00
parent a663462e27
commit bb08ec56ce
35 changed files with 1471 additions and 194 deletions

View File

@ -2,16 +2,17 @@ package config
// Pagination sizes per page.
var (
PageSizeMemberSearch = 60
PageSizeFriends = 12
PageSizeBlockList = 12
PageSizeAdminCertification = 20
PageSizeAdminFeedback = 20
PageSizeSiteGallery = 18
PageSizeUserGallery = 18
PageSizeInboxList = 20 // sidebar list
PageSizeInboxThread = 20 // conversation view
PageSizeForums = 100 // TODO: for main category index view
PageSizeThreadList = 20
PageSizeForumAdmin = 20
PageSizeMemberSearch = 60
PageSizeFriends = 12
PageSizeBlockList = 12
PageSizeAdminCertification = 20
PageSizeAdminFeedback = 20
PageSizeSiteGallery = 18
PageSizeUserGallery = 18
PageSizeInboxList = 20 // sidebar list
PageSizeInboxThread = 20 // conversation view
PageSizeForums = 100 // TODO: for main category index view
PageSizeThreadList = 20 // 20 threads per board, 20 posts per thread
PageSizeForumAdmin = 20
PageSizeDashboardNotifications = 50
)

View File

@ -3,6 +3,9 @@ package account
import (
"net/http"
"git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/models"
"git.kirsle.net/apps/gosocial/pkg/session"
"git.kirsle.net/apps/gosocial/pkg/templates"
)
@ -10,7 +13,41 @@ import (
func Dashboard() http.HandlerFunc {
tmpl := templates.Must("account/dashboard.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := tmpl.Execute(w, r, nil); err != nil {
currentUser, err := session.CurrentUser(r)
if err != nil {
http.Error(w, "Couldn't get currentUser", http.StatusInternalServerError)
return
}
// Mark all notifications read?
if r.FormValue("intent") == "read-notifications" {
models.MarkNotificationsRead(currentUser)
session.Flash(w, r, "All of your notifications have been marked as 'read!'")
templates.Redirect(w, "/me")
return
}
// Get our notifications.
pager := &models.Pagination{
Page: 1,
PerPage: config.PageSizeDashboardNotifications,
Sort: "created_at desc",
}
pager.ParsePage(r)
notifs, err := models.PaginateNotifications(currentUser, pager)
if err != nil {
session.FlashError(w, r, "Couldn't get your notifications: %s", err)
}
// Map our notifications.
notifMap := models.MapNotifications(notifs)
var vars = map[string]interface{}{
"Notifications": notifs,
"NotifMap": notifMap,
"Pager": pager,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

View File

@ -57,8 +57,12 @@ func Profile() http.HandlerFunc {
isPrivate = !currentUser.IsAdmin && !isSelf && user.Visibility == models.UserVisibilityPrivate && isFriend != "approved"
)
// Get Likes for this profile.
likeMap := models.MapLikes(currentUser, "users", []uint64{user.ID})
vars := map[string]interface{}{
"User": user,
"LikeMap": likeMap,
"IsFriend": isFriend,
"IsPrivate": isPrivate,
}

View File

@ -89,6 +89,20 @@ func Feedback() http.HandlerFunc {
return
}
}
case "comments":
// Get this comment.
comment, err := models.GetComment(fb.TableID)
if err != nil {
session.FlashError(w, r, "Couldn't get comment ID %d: %s", fb.TableID, err)
} else {
// What was the comment on?
switch comment.TableName {
case "threads":
// Visit the thread.
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", comment.TableID))
return
}
}
default:
session.FlashError(w, r, "Couldn't visit TableID %s/%d: not a supported TableName", fb.TableName, fb.TableID)
}

View File

@ -0,0 +1,55 @@
package api
import (
"encoding/json"
"errors"
"io/ioutil"
"net/http"
"git.kirsle.net/apps/gosocial/pkg/log"
)
// Envelope is the standard JSON response envelope.
type Envelope struct {
Data interface{} `json:"data"`
StatusCode int
}
// ParseJSON request body.
func ParseJSON(r *http.Request, v interface{}) error {
if r.Header.Get("Content-Type") != "application/json" {
return errors.New("request Content-Type must be application/json")
}
// Parse request body.
body, err := ioutil.ReadAll(r.Body)
if err != nil {
return err
}
log.Error("body: %+v", body)
// Parse params from JSON.
if err := json.Unmarshal(body, v); err != nil {
return err
}
return nil
}
// SendJSON response.
func SendJSON(w http.ResponseWriter, statusCode int, v interface{}) {
buf, err := json.Marshal(Envelope{
Data: v,
StatusCode: statusCode,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
w.Write(buf)
}

124
pkg/controller/api/likes.go Normal file
View File

@ -0,0 +1,124 @@
package api
import (
"fmt"
"net/http"
"git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/models"
"git.kirsle.net/apps/gosocial/pkg/session"
)
// Likes API.
func Likes() http.HandlerFunc {
// Request JSON schema.
type Request struct {
TableName string `json:"name"`
TableID uint64 `json:"id"`
Unlike bool `json:"unlike,omitempty"`
}
// Response JSON schema.
type Response struct {
OK bool `json:"OK"`
Error string `json:"error,omitempty"`
Likes int64 `json:"likes"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
SendJSON(w, http.StatusNotAcceptable, Response{
Error: "POST method only",
})
return
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
SendJSON(w, http.StatusBadRequest, Response{
Error: "Couldn't get current user!",
})
return
}
// Parse request payload.
var req Request
if err := ParseJSON(r, &req); err != nil {
SendJSON(w, http.StatusBadRequest, Response{
Error: fmt.Sprintf("Error with request payload: %s", err),
})
return
}
// Who do we notify about this like?
var targetUser *models.User
switch req.TableName {
case "photos":
if photo, err := models.GetPhoto(req.TableID); err == nil {
if user, err := models.GetUser(photo.UserID); err == nil {
targetUser = user
}
} else {
log.Error("For like on photos table: didn't find photo %d: %s", req.TableID, err)
}
case "users":
log.Error("subject is users, find %d", req.TableID)
if user, err := models.GetUser(req.TableID); err == nil {
targetUser = user
log.Warn("found user %s", targetUser.Username)
} else {
log.Error("For like on users table: didn't find user %d: %s", req.TableID, err)
}
}
// Is the table likeable?
if _, ok := models.LikeableTables[req.TableName]; !ok {
SendJSON(w, http.StatusBadRequest, Response{
Error: fmt.Sprintf("Can't like table %s: not allowed.", req.TableName),
})
return
}
// Put in a like.
if req.Unlike {
if err := models.Unlike(currentUser, req.TableName, req.TableID); err != nil {
SendJSON(w, http.StatusBadRequest, Response{
Error: fmt.Sprintf("Error unliking: %s", err),
})
return
}
// Remove the target's notification about this like.
models.RemoveNotification(req.TableName, req.TableID)
} else {
if err := models.AddLike(currentUser, req.TableName, req.TableID); err != nil {
SendJSON(w, http.StatusBadRequest, Response{
Error: fmt.Sprintf("Error liking: %s", err),
})
return
}
// Notify the recipient of the like.
log.Info("Added like on %s:%d, notifying owner %+v", req.TableName, req.TableID, targetUser)
if targetUser != nil {
notif := &models.Notification{
UserID: targetUser.ID,
User: *currentUser,
Type: models.NotificationLike,
TableName: req.TableName,
TableID: req.TableID,
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create Likes notification: %s", err)
}
}
}
// Send success response.
SendJSON(w, http.StatusOK, Response{
OK: true,
Likes: models.CountLikes(req.TableName, req.TableID),
})
})
}

View File

@ -0,0 +1,76 @@
package api
import (
"fmt"
"net/http"
"git.kirsle.net/apps/gosocial/pkg/models"
"git.kirsle.net/apps/gosocial/pkg/session"
)
// ReadNotification API to mark a notif ID as "read."
func ReadNotification() http.HandlerFunc {
// Request JSON schema.
type Request struct {
ID uint64 `json:"id"`
}
// Response JSON schema.
type Response struct {
OK bool `json:"OK"`
Error string `json:"error,omitempty"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
SendJSON(w, http.StatusNotAcceptable, Response{
Error: "POST method only",
})
return
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
SendJSON(w, http.StatusBadRequest, Response{
Error: "Couldn't get current user!",
})
return
}
// Parse request payload.
var req Request
if err := ParseJSON(r, &req); err != nil {
SendJSON(w, http.StatusBadRequest, Response{
Error: fmt.Sprintf("Error with request payload: %s", err),
})
return
}
// Get this notification.
notif, err := models.GetNotification(req.ID)
if err != nil {
SendJSON(w, http.StatusInternalServerError, Response{
Error: err.Error(),
})
return
}
// Ensure it's ours to read.
if notif.UserID != currentUser.ID {
SendJSON(w, http.StatusForbidden, Response{
Error: "That is not your notification.",
})
return
}
// Mark it read.
notif.Read = true
notif.Save()
// Send success response.
SendJSON(w, http.StatusOK, Response{
OK: true,
})
})
}

View File

@ -41,6 +41,14 @@ func Forum() http.HandlerFunc {
return
}
// Get the pinned threads.
pinned, err := models.PinnedThreads(forum)
if err != nil {
session.FlashError(w, r, "Couldn't get pinned threads: %s", err)
templates.Redirect(w, "/")
return
}
// Get all the categorized index forums.
// XXX: we get a large page size to get ALL official forums
var pager = &models.Pagination{
@ -57,6 +65,9 @@ func Forum() http.HandlerFunc {
return
}
// Inject pinned threads on top.
threads = append(pinned, threads...)
// Map the statistics (replies, views) of these threads.
threadMap := models.MapThreadStatistics(threads)

View File

@ -51,7 +51,7 @@ func Landing() http.HandlerFunc {
}
pager.ParsePage(r)
forums, err := models.PaginateForums(currentUser.ID, config.ForumCategories, pager)
forums, err := models.PaginateForums(currentUser, config.ForumCategories, pager)
if err != nil {
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
templates.Redirect(w, "/")

View File

@ -24,12 +24,18 @@ func NewPost() http.HandlerFunc {
intent = r.FormValue("intent") // preview or submit
title = r.FormValue("title") // for new forum post only
message = r.PostFormValue("message") // comment body
isPinned = r.PostFormValue("pinned") == "true" // owners or admins only
isExplicit = r.PostFormValue("explicit") == "true" // for thread only
isNoReply = r.PostFormValue("noreply") == "true" // for thread only
isDelete = r.FormValue("delete") == "true" // delete comment (along with edit=$id)
forum *models.Forum
thread *models.Thread // if replying to a thread
comment *models.Comment // if editing a comment
// If we are modifying a comment (post) and it's the OG post of the
// thread, we show and accept the thread settings to be updated as
// well (pinned, explicit, noreply)
isOriginalComment bool
)
// Get the current user.
@ -88,6 +94,18 @@ func NewPost() http.HandlerFunc {
message = comment.Message
}
// Is this the OG thread of the post?
if thread.CommentID == comment.ID {
isOriginalComment = true
// Restore the checkbox option form values from thread settings.
if r.Method == http.MethodGet {
isPinned = thread.Pinned
isExplicit = thread.Explicit
isNoReply = thread.NoReply
}
}
// Are we DELETING this comment?
if isDelete {
if err := thread.DeleteReply(comment); err != nil {
@ -116,6 +134,17 @@ func NewPost() http.HandlerFunc {
// Are we modifying an existing comment?
if comment != nil {
comment.Message = message
// Can we update the thread props?
if isOriginalComment {
thread.Pinned = isPinned
thread.Explicit = isExplicit
thread.NoReply = isNoReply
if err := thread.Save(); err != nil {
session.FlashError(w, r, "Couldn't save thread properties: %s", err)
}
}
if err := comment.Save(); err != nil {
session.FlashError(w, r, "Couldn't save comment: %s", err)
} else {
@ -142,6 +171,7 @@ func NewPost() http.HandlerFunc {
forum.ID,
title,
message,
isPinned,
isExplicit,
isNoReply,
); err != nil {
@ -155,12 +185,18 @@ func NewPost() http.HandlerFunc {
}
var vars = map[string]interface{}{
"Forum": forum,
"Thread": thread,
"Intent": intent,
"PostTitle": title,
"EditCommentID": editCommentID,
"Message": message,
"Forum": forum,
"Thread": thread,
"Intent": intent,
"PostTitle": title,
"EditCommentID": editCommentID,
"EditThreadSettings": isOriginalComment,
"Message": message,
// Thread settings (for editing the original comment esp.)
"IsPinned": isPinned,
"IsExplicit": isExplicit,
"IsNoReply": isNoReply,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -65,6 +65,15 @@ func Contact() http.HandlerFunc {
case "report.message":
tableName = "messages"
tableLabel = "Direct Message conversation"
case "report.comment":
tableName = "comments"
// Find this comment.
if comment, err := models.GetComment(uint64(tableID)); err == nil {
tableLabel = fmt.Sprintf(`A comment written by "%s"`, comment.User.Username)
} else {
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err)
}
}
}

View File

@ -46,10 +46,18 @@ func SiteGallery() http.HandlerFunc {
session.FlashError(w, r, "Failed to MapUsers: %s", err)
}
// Get Likes information about these photos.
var photoIDs = []uint64{}
for _, p := range photos {
photoIDs = append(photoIDs, p.ID)
}
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
var vars = map[string]interface{}{
"IsSiteGallery": true,
"Photos": photos,
"UserMap": userMap,
"LikeMap": likeMap,
"Pager": pager,
"ViewStyle": viewStyle,
}

View File

@ -91,11 +91,19 @@ func UserPhotos() http.HandlerFunc {
explicitCount, _ = models.CountExplicitPhotos(user.ID, visibility)
}
// Get Likes information about these photos.
var photoIDs = []uint64{}
for _, p := range photos {
photoIDs = append(photoIDs, p.ID)
}
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
var vars = map[string]interface{}{
"IsOwnPhotos": currentUser.ID == user.ID,
"User": user,
"Photos": photos,
"Pager": pager,
"LikeMap": likeMap,
"ViewStyle": viewStyle,
"ExplicitCount": explicitCount,
}

View File

@ -18,6 +18,12 @@ func CSRF(handler http.Handler) http.Handler {
token := MakeCSRFCookie(r, w)
ctx := context.WithValue(r.Context(), session.CSRFKey, token)
// If it's a JSON post, allow it thru.
if r.Header.Get("Content-Type") == "application/json" {
handler.ServeHTTP(w, r.WithContext(ctx))
return
}
// If we are running a POST request, validate the CSRF form value.
if r.Method != http.MethodGet {
r.ParseMultipartForm(config.MultipartMaxMemory)

View File

@ -19,6 +19,10 @@ func DeleteUser(user *models.User) error {
}
var todo = []remover{
{"Notifications", DeleteNotifications},
{"Likes", DeleteLikes},
{"Threads", DeleteForumThreads},
{"Comments", DeleteComments},
{"Photos", DeleteUserPhotos},
{"Certification Photo", DeleteCertification},
{"Messages", DeleteUserMessages},
@ -112,6 +116,26 @@ func DeleteFriends(userID uint64) error {
return result.Error
}
// DeleteNotifications scrubs all notifications about a user.
func DeleteNotifications(userID uint64) error {
log.Error("DeleteUser: DeleteNotifications(%d)", userID)
result := models.DB.Where(
"user_id = ? OR about_user_id = ?",
userID, userID,
).Delete(&models.Notification{})
return result.Error
}
// DeleteLikes scrubs all Likes about a user.
func DeleteLikes(userID uint64) error {
log.Error("DeleteUser: DeleteLikes(%d)", userID)
result := models.DB.Where(
"user_id = ? OR (table_name='users' AND table_id=?)",
userID, userID,
).Delete(&models.Like{})
return result.Error
}
// DeleteProfile scrubs data for deleting a user.
func DeleteProfile(userID uint64) error {
log.Error("DeleteUser: DeleteProfile(%d)", userID)
@ -121,3 +145,57 @@ func DeleteProfile(userID uint64) error {
).Delete(&models.ProfileField{})
return result.Error
}
// DeleteForumThreads scrubs all forum threads started by the user.
func DeleteForumThreads(userID uint64) error {
log.Error("DeleteUser: DeleteForumThreads(%d)", userID)
var threadIDs = []uint64{}
result := models.DB.Table(
"threads",
).Joins(
"JOIN comments ON (threads.comment_id = comments.id)",
).Select(
"distinct(threads.id) as id",
).Where(
"comments.user_id = ?",
userID,
).Scan(&threadIDs)
if result.Error != nil {
return fmt.Errorf("Couldn't list thread IDs created by user: %s", result.Error)
}
log.Warn("thread IDs to wipe: %+v", threadIDs)
// Wipe all these threads and their comments.
if len(threadIDs) > 0 {
result = models.DB.Where(
"table_name = ? AND table_id IN ?",
"threads", threadIDs,
).Delete(&models.Comment{})
if result.Error != nil {
return fmt.Errorf("Couldn't wipe threads of comments: %s", result.Error)
}
// And finish the threads off too.
result = models.DB.Where(
"id IN ?",
threadIDs,
).Delete(&models.Thread{})
return result.Error
}
return nil
}
// DeleteComments deletes all comments by the user.
func DeleteComments(userID uint64) error {
log.Error("DeleteUser: DeleteComments(%d)", userID)
result := models.DB.Where(
"user_id = ?",
userID,
).Delete(&models.Comment{})
return result.Error
}

View File

@ -62,7 +62,7 @@ Parameters:
- categories: optional, filter within categories
- pager
*/
func PaginateForums(userID uint64, categories []string, pager *Pagination) ([]*Forum, error) {
func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Forum, error) {
var (
fs = []*Forum{}
query = (&Forum{}).Preload()
@ -75,6 +75,11 @@ func PaginateForums(userID uint64, categories []string, pager *Pagination) ([]*F
placeholders = append(placeholders, categories)
}
// Hide explicit forum if user hasn't opted into it.
if !user.Explicit && !user.IsAdmin {
wheres = append(wheres, "explicit = false")
}
// Filters?
if len(wheres) > 0 {
query = query.Where(

View File

@ -25,119 +25,11 @@ func MapForumStatistics(forums []*Forum) ForumStatsMap {
result[forum.ID] = &ForumStatistics{}
}
// FIRST: count the threads in each forum.
{
// Hold the result of the count/group by query.
type group struct {
ID uint64
Threads uint64
}
var groups = []group{}
// Count comments grouped by thread IDs.
err := DB.Table(
"threads",
).Select(
"forum_id AS id, count(id) AS threads",
).Where(
"forum_id IN ?",
IDs,
).Group("forum_id").Scan(&groups)
if err.Error != nil {
log.Error("MapForumStatistics: SQL error: %s", err.Error)
}
// Map the results in.
for _, row := range groups {
log.Error("Got row: %+v", row)
if stats, ok := result[row.ID]; ok {
stats.Threads = row.Threads
}
}
}
// THEN: count all posts in all those threads.
{
type group struct {
ID uint64
Posts uint64
}
var groups = []group{}
err := DB.Table(
"comments",
).Joins(
"JOIN threads ON (table_name = 'threads' AND table_id = threads.id)",
).Joins(
"JOIN forums ON (threads.forum_id = forums.id)",
).Select(
"forums.id AS id, count(comments.id) AS posts",
).Where(
`table_name = 'threads' AND EXISTS (
SELECT 1
FROM threads
WHERE table_id = threads.id
AND threads.forum_id IN ?
)`,
IDs,
).Group("forums.id").Scan(&groups)
if err.Error != nil {
log.Error("SQL error collecting posts for forum: %s", err.Error)
}
// Map the results in.
for _, row := range groups {
log.Error("Got row2: %+v", row)
if stats, ok := result[row.ID]; ok {
stats.Posts = row.Posts
}
}
}
// THEN: count all distinct users in those threads.
// TODO: hairy
// {
// type group struct {
// ID uint64
// Users uint64
// }
// var groups = []group{}
// err := DB.Table(
// "comments",
// ).Joins(
// "JOIN threads ON (table_name = 'threads' AND table_id = threads.id)",
// ).Joins(
// "JOIN forums ON (threads.forum_id = forums.id)",
// ).Select(
// "forums.id AS forum_id, count(distinct(comments.user_id)) AS users",
// ).Where(
// // `table_name = 'threads' AND EXISTS (
// // SELECT 1
// // FROM threads
// // WHERE table_id = threads.id
// // AND threads.forum_id IN ?
// // )`,
// "forums.id IN ?",
// IDs,
// ).Group("forums.id").Scan(&groups)
// if err.Error != nil {
// log.Error("SQL error collecting users for forum: %s", err.Error)
// }
// // Map the results in.
// for _, row := range groups {
// log.Error("Got row2: %+v", row)
// if stats, ok := result[row.ID]; ok {
// stats.Users = row.Users
// }
// }
// }
// Get THE most recent thread on this forum.
// Gather all the statistics.
result.generateThreadCount(IDs)
result.generatePostCount(IDs)
result.generateUserCount(IDs)
result.generateRecentThreads(IDs)
return result
}
@ -155,3 +47,157 @@ func (ts ForumStatsMap) Get(threadID uint64) *ForumStatistics {
}
return nil
}
// Compute the count of threads in each of the forum 'IDs'.
func (ts ForumStatsMap) generateThreadCount(IDs []uint64) {
// Hold the result of the count/group by query.
type group struct {
ID uint64
Threads uint64
}
var groups = []group{}
// Count comments grouped by thread IDs.
err := DB.Table(
"threads",
).Select(
"forum_id AS id, count(id) AS threads",
).Where(
"forum_id IN ?",
IDs,
).Group("forum_id").Scan(&groups)
if err.Error != nil {
log.Error("MapForumStatistics: SQL error: %s", err.Error)
}
// Map the results in.
for _, row := range groups {
if stats, ok := ts[row.ID]; ok {
stats.Threads = row.Threads
}
}
}
// Compute the count of all posts in each of the forum 'IDs'.
func (ts ForumStatsMap) generatePostCount(IDs []uint64) {
type group struct {
ID uint64
Posts uint64
}
var groups = []group{}
err := DB.Table(
"comments",
).Joins(
"JOIN threads ON (table_name = 'threads' AND table_id = threads.id)",
).Joins(
"JOIN forums ON (threads.forum_id = forums.id)",
).Select(
"forums.id AS id, count(comments.id) AS posts",
).Where(
`table_name = 'threads' AND EXISTS (
SELECT 1
FROM threads
WHERE table_id = threads.id
AND threads.forum_id IN ?
)`,
IDs,
).Group("forums.id").Scan(&groups)
if err.Error != nil {
log.Error("SQL error collecting posts for forum: %s", err.Error)
}
// Map the results in.
for _, row := range groups {
if stats, ok := ts[row.ID]; ok {
stats.Posts = row.Posts
}
}
}
// Compute the count of all users in each of the forum 'IDs'.
func (ts ForumStatsMap) generateUserCount(IDs []uint64) {
type group struct {
ForumID uint64
Users uint64
}
var groups = []group{}
err := DB.Table(
"comments",
).Joins(
"JOIN threads ON (table_name = 'threads' AND table_id = threads.id)",
).Joins(
"JOIN forums ON (threads.forum_id = forums.id)",
).Select(
"forums.id AS forum_id, count(distinct(comments.user_id)) AS users",
).Where(
"forums.id IN ?",
IDs,
).Group("forums.id").Scan(&groups)
if err.Error != nil {
log.Error("SQL error collecting users for forum: %s", err.Error)
}
// Map the results in.
for _, row := range groups {
if stats, ok := ts[row.ForumID]; ok {
stats.Users = row.Users
}
}
}
// Compute the recent threads for each of the forum 'IDs'.
func (ts ForumStatsMap) generateRecentThreads(IDs []uint64) {
var threadIDs = []map[string]interface{}{}
err := DB.Table(
"threads",
).Select(
"forum_id, id AS thread_id, updated_at",
).Where(
`updated_at = (SELECT MAX(updated_at)
FROM threads t2
WHERE threads.forum_id = t2.forum_id)
AND threads.forum_id IN ?`,
IDs,
).Order(
"updated_at desc",
).Scan(&threadIDs)
if err.Error != nil {
log.Error("Getting most recent thread IDs: %s", err.Error)
}
// Map them easier.
var (
threadForumMap = map[uint64]uint64{}
allThreadIDs = []uint64{}
)
for _, row := range threadIDs {
if row["thread_id"] == nil || row["forum_id"] == nil {
continue
}
var (
threadID = uint64(row["thread_id"].(int64))
forumID = uint64(row["forum_id"].(int64))
)
allThreadIDs = append(allThreadIDs, threadID)
threadForumMap[threadID] = forumID
}
// Select and map these threads in.
if threadMap, err := GetThreads(allThreadIDs); err == nil {
for threadID, thread := range threadMap {
if forumID, ok := threadForumMap[threadID]; ok {
if stats, ok := ts[forumID]; ok {
stats.RecentThread = thread
}
}
}
}
}

145
pkg/models/like.go Normal file
View File

@ -0,0 +1,145 @@
package models
import (
"time"
"git.kirsle.net/apps/gosocial/pkg/log"
)
// Like table.
type Like struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"` // who it belongs to
TableName string
TableID uint64
CreatedAt time.Time
UpdatedAt time.Time
}
// LikeableTables are the set of table names that allow likes (used by the JSON API).
var LikeableTables = map[string]interface{}{
"photos": nil,
"users": nil,
}
// AddLike to something.
func AddLike(user *User, tableName string, tableID uint64) error {
// Already has a like?
var like = &Like{}
exist := DB.Model(like).Where(
"user_id = ? AND table_name = ? AND table_id = ?",
user.ID, tableName, tableID,
).First(&like)
if exist.Error == nil {
return nil
}
// Create it.
like = &Like{
UserID: user.ID,
TableName: tableName,
TableID: tableID,
}
return DB.Create(like).Error
}
// Unlike something.
func Unlike(user *User, tableName string, tableID uint64) error {
result := DB.Where(
"user_id = ? AND table_name = ? AND table_id = ?",
user.ID, tableName, tableID,
).Delete(&Like{})
return result.Error
}
// CountLikes on something.
func CountLikes(tableName string, tableID uint64) int64 {
var count int64
DB.Model(&Like{}).Where(
"table_name = ? AND table_id = ?",
tableName, tableID,
).Count(&count)
return count
}
// LikedIDs filters a set of table IDs to ones the user likes.
func LikedIDs(user *User, tableName string, tableIDs []uint64) ([]uint64, error) {
var result = []uint64{}
if r := DB.Table(
"likes",
).Select(
"table_id",
).Where(
"user_id = ? AND table_name = ? AND table_id IN ?",
user.ID, tableName, tableIDs,
).Scan(&result); r.Error != nil {
return result, r.Error
}
return result, nil
}
// LikeMap maps table IDs to Likes metadata.
type LikeMap map[uint64]*LikeStats
// Get like stats from the map.
func (lm LikeMap) Get(id uint64) *LikeStats {
if stats, ok := lm[id]; ok {
return stats
}
return &LikeStats{}
}
// LikeStats holds mapped statistics about liked objects.
type LikeStats struct {
Count int64 // how many total
UserLikes bool // current user likes it
}
// MapLikes over a set of table IDs.
func MapLikes(user *User, tableName string, tableIDs []uint64) LikeMap {
var result = LikeMap{}
// Initialize the result set.
for _, id := range tableIDs {
result[id] = &LikeStats{}
}
// Hold the result of the grouped count query.
type group struct {
ID uint64
Likes int64
}
var groups = []group{}
// Map the counts of likes to each of these IDs.
if res := DB.Table(
"likes",
).Select(
"table_id AS id, count(id) AS likes",
).Where(
"table_name = ? AND table_id IN ?",
tableName, tableIDs,
).Group("table_id").Scan(&groups); res.Error != nil {
log.Error("MapLikes: count query: %s", res.Error)
}
// Map the counts back in.
for _, row := range groups {
if stats, ok := result[row.ID]; ok {
stats.Count = row.Likes
}
}
// Does the CURRENT USER like any of these IDs?
if likedIDs, err := LikedIDs(user, tableName, tableIDs); err == nil {
log.Error("USER LIKES IDS: %+v", likedIDs)
for _, id := range likedIDs {
if stats, ok := result[id]; ok {
stats.UserLikes = true
}
}
}
return result
}

View File

@ -19,4 +19,6 @@ func AutoMigrate() {
DB.AutoMigrate(&Forum{})
DB.AutoMigrate(&Thread{})
DB.AutoMigrate(&Comment{})
DB.AutoMigrate(&Like{})
DB.AutoMigrate(&Notification{})
}

175
pkg/models/notification.go Normal file
View File

@ -0,0 +1,175 @@
package models
import (
"time"
"git.kirsle.net/apps/gosocial/pkg/log"
"gorm.io/gorm"
)
// Notification table.
type Notification struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"` // who it belongs to
AboutUserID *uint64 `form:"index"` // the other party of this notification
User User `gorm:"foreignKey:about_user_id"`
Type NotificationType // like, comment, ...
Read bool `gorm:"index"`
TableName string // on which of your tables (photos, comments, ...)
TableID uint64
Message string // text associated, e.g. copy of comment added
CreatedAt time.Time
UpdatedAt time.Time
}
// Preload related tables for the forum (classmethod).
func (n *Notification) Preload() *gorm.DB {
return DB.Preload("User.ProfilePhoto")
}
type NotificationType string
const (
NotificationLike NotificationType = "like"
NotificationComment = "comment"
NotificationCustom = "custom" // custom message pushed
)
// CreateNotification
func CreateNotification(n *Notification) error {
return DB.Create(n).Error
}
// GetNotification by ID.
func GetNotification(id uint64) (*Notification, error) {
var n *Notification
result := DB.Model(n).First(&n, id)
return n, result.Error
}
// RemoveNotification about a table ID, e.g. when removing a like.
func RemoveNotification(tableName string, tableID uint64) error {
result := DB.Where(
"table_name = ? AND table_id = ?",
tableName, tableID,
).Delete(&Notification{})
return result.Error
}
// MarkNotificationsRead sets all a user's notifications to read.
func MarkNotificationsRead(user *User) error {
return DB.Model(&Notification{}).Where(
"user_id = ? AND read IS NOT TRUE",
user.ID,
).Update("read", true).Error
}
// CountUnreadNotifications gets the count of unread Notifications for a user.
func CountUnreadNotifications(userID uint64) (int64, error) {
query := DB.Where(
"user_id = ? AND read = ?",
userID,
false,
)
var count int64
result := query.Model(&Notification{}).Count(&count)
return count, result.Error
}
// PaginateNotifications returns the user's notifications.
func PaginateNotifications(user *User, pager *Pagination) ([]*Notification, error) {
var ns = []*Notification{}
query := (&Notification{}).Preload().Where(
"user_id = ?",
user.ID,
).Order(
pager.Sort,
)
query.Model(&Notification{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ns)
return ns, result.Error
}
// Save a notification.
func (n *Notification) Save() error {
return DB.Save(n).Error
}
// NotificationBody can store remote tables mapped.
type NotificationBody struct {
PhotoID uint64
Photo *Photo
}
type NotificationMap map[uint64]*NotificationBody
// Get a notification's body from the map.
func (m NotificationMap) Get(id uint64) *NotificationBody {
if body, ok := m[id]; ok {
return body
}
return &NotificationBody{}
}
// MapNotifications loads associated assets, like Photos, mapped to their notification ID.
func MapNotifications(ns []*Notification) NotificationMap {
var (
IDs = []uint64{}
result = NotificationMap{}
)
// Collect notification IDs.
for _, row := range ns {
IDs = append(IDs, row.ID)
result[row.ID] = &NotificationBody{}
}
type scanner struct {
PhotoID uint64
NotificationID uint64
}
var scan []scanner
// Load all of these that have photos.
err := DB.Table(
"notifications",
).Joins(
"JOIN photos ON (notifications.table_name='photos' AND notifications.table_id=photos.id)",
).Select(
"photos.id AS photo_id",
"notifications.id AS notification_id",
).Where(
"notifications.id IN ?",
IDs,
).Scan(&scan)
if err != nil {
log.Error("Couldn't select photo IDs for notifications: %s", err)
}
// Collect and load all the photos by ID.
var photoIDs = []uint64{}
for _, row := range scan {
// Store the photo ID in the result now.
result[row.NotificationID].PhotoID = row.PhotoID
photoIDs = append(photoIDs, row.PhotoID)
}
// Load the photos.
if len(photoIDs) > 0 {
if photos, err := GetPhotos(photoIDs); err != nil {
log.Error("Couldn't load photo IDs for notifications: %s", err)
} else {
// Marry them to their notification IDs.
for _, body := range result {
if photo, ok := photos[body.PhotoID]; ok {
body.Photo = photo
}
}
}
}
return result
}

View File

@ -76,6 +76,21 @@ func GetPhoto(id uint64) (*Photo, error) {
return p, result.Error
}
// GetPhotos by an array of IDs, mapped to their IDs.
func GetPhotos(IDs []uint64) (map[uint64]*Photo, error) {
var (
mp = map[uint64]*Photo{}
ps = []*Photo{}
)
result := DB.Model(&Photo{}).Where("id IN ?", IDs).Find(&ps)
for _, row := range ps {
mp[row.ID] = row
}
return mp, result.Error
}
/*
PaginateUserPhotos gets a page of photos belonging to a user ID.
*/

View File

@ -3,6 +3,7 @@ package models
import (
"errors"
"fmt"
"strings"
"time"
"git.kirsle.net/apps/gosocial/pkg/log"
@ -14,6 +15,7 @@ type Thread struct {
ID uint64 `gorm:"primaryKey"`
ForumID uint64 `gorm:"index"`
Forum Forum
Pinned bool `gorm:"index"`
Explicit bool `gorm:"index"`
NoReply bool
Title string
@ -36,11 +38,27 @@ func GetThread(id uint64) (*Thread, error) {
return t, result.Error
}
// GetThreads queries a set of thread IDs and returns them mapped.
func GetThreads(IDs []uint64) (map[uint64]*Thread, error) {
var (
mt = map[uint64]*Thread{}
ts = []*Thread{}
)
result := (&Thread{}).Preload().Where("id IN ?", IDs).Find(&ts)
for _, row := range ts {
mt[row.ID] = row
}
return mt, result.Error
}
// CreateThread creates a new thread with proper Comment structure.
func CreateThread(user *User, forumID uint64, title, message string, explicit, noReply bool) (*Thread, error) {
func CreateThread(user *User, forumID uint64, title, message string, pinned, explicit, noReply bool) (*Thread, error) {
thread := &Thread{
ForumID: forumID,
Title: title,
Pinned: pinned,
Explicit: explicit,
NoReply: noReply && user.IsAdmin,
Comment: Comment{
@ -92,16 +110,41 @@ func (t *Thread) DeleteReply(comment *Comment) error {
return comment.Delete()
}
// PaginateThreads provides a forum index view of posts.
func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, error) {
// PinnedThreads returns all pinned threads in a forum (there should generally be few of these).
func PinnedThreads(forum *Forum) ([]*Thread, error) {
var (
ts = []*Thread{}
query = (&Thread{}).Preload()
query = (&Thread{}).Preload().Where(
"forum_id = ? AND pinned IS TRUE",
forum.ID,
).Order("updated_at desc")
)
result := query.Find(&ts)
return ts, result.Error
}
// PaginateThreads provides a forum index view of posts, minus pinned posts.
func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, error) {
var (
ts = []*Thread{}
query = (&Thread{}).Preload()
wheres = []string{}
placeholders = []interface{}{}
)
// Always filters.
wheres = append(wheres, "forum_id = ? AND pinned IS NOT TRUE")
placeholders = append(placeholders, forum.ID)
// If the user hasn't opted in for Explicit, hide NSFW threads.
if !user.Explicit && !user.IsAdmin {
wheres = append(wheres, "explicit IS NOT TRUE")
}
query = query.Where(
"forum_id = ?",
forum.ID,
strings.Join(wheres, " AND "),
placeholders...,
).Order(pager.Sort)
query.Model(&Thread{}).Count(&pager.Total)

View File

@ -72,6 +72,8 @@ func New() http.Handler {
// JSON API endpoints.
mux.HandleFunc("/v1/version", api.Version())
mux.HandleFunc("/v1/users/me", api.LoginOK())
mux.Handle("/v1/likes", middleware.LoginRequired(api.Likes()))
mux.Handle("/v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))
// Static files.
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath))))

View File

@ -13,6 +13,8 @@ import (
// MergeVars mixes in globally available template variables. The http.Request is optional.
func MergeVars(r *http.Request, m map[string]interface{}) {
m["Title"] = config.Title
m["BuildHash"] = config.RuntimeBuild
m["BuildDate"] = config.RuntimeBuildDate
m["Subtitle"] = config.Subtitle
m["YYYY"] = time.Now().Year()
@ -31,9 +33,10 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
m["SessionImpersonated"] = false
// User notification counts for nav bar.
m["NavUnreadMessages"] = 0 // New messages
m["NavFriendRequests"] = 0 // Friend requests
m["NavTotalNotifications"] = 0 // Total of above
m["NavUnreadMessages"] = 0 // New messages
m["NavFriendRequests"] = 0 // Friend requests
m["NavUnreadNotifications"] = 0 // general notifications
m["NavTotalNotifications"] = 0 // Total of above
// Admin notification counts for nav bar.
m["NavCertificationPhotos"] = 0 // Cert. photos needing approval
@ -50,11 +53,21 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
m["LoggedIn"] = true
m["CurrentUser"] = user
// Get user recent notifications.
/*notifPager := &models.Pagination{
Page: 1,
PerPage: 10,
}
if notifs, err := models.PaginateNotifications(user, notifPager); err == nil {
m["Notifications"] = notifs
}*/
// Collect notification counts.
var (
// For users
countMessages int64
countFriendReqs int64
countMessages int64
countFriendReqs int64
countNotifications int64
// For admins
countCertPhotos int64
@ -77,6 +90,14 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err)
}
// Count other notifications.
if count, err := models.CountUnreadNotifications(user.ID); err == nil {
m["NavUnreadNotifications"] = count
countNotifications = count
} else {
log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err)
}
// Are we admin?
if user.IsAdmin {
// Any pending certification photos or feedback?
@ -90,6 +111,6 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
}
// Total count for user notifications.
m["NavTotalNotifications"] = countMessages + countFriendReqs + countCertPhotos + countFeedback
m["NavTotalNotifications"] = countMessages + countFriendReqs + countNotifications + countCertPhotos + countFeedback
}
}

56
web/static/js/likes.js Normal file
View File

@ -0,0 +1,56 @@
// Like button handler.
document.addEventListener('DOMContentLoaded', () => {
const red = "has-text-danger";
let busy = false;
// Bind to the like buttons.
(document.querySelectorAll(".nonshy-like-button") || []).forEach(node => {
node.addEventListener("click", (e) => {
if (busy) return;
let $icon = node.querySelector(".icon"),
$label = node.querySelector(".nonshy-likes"),
tableName = node.dataset.tableName,
tableID = node.dataset.tableId,
liking = false;
// Toggle the color of the heart.
if ($icon.classList.contains(red)) {
$icon.classList.remove(red);
} else {
liking = true;
$icon.classList.add(red);
}
// Ajax request to backend.
busy = true;
return fetch("/v1/likes", {
method: "POST",
mode: "same-origin",
cache: "no-cache",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
"name": tableName, // TODO
"id": parseInt(tableID),
"unlike": !liking,
}),
})
.then((response) => response.json())
.then((data) => {
let likes = data.data.likes;
if (likes === 0) {
$label.innerHTML = "Like";
} else {
$label.innerHTML = `Like (${likes})`;
}
}).catch(resp => {
window.alert(resp);
}).finally(() => {
busy = false;
})
});
});
});

View File

@ -136,18 +136,157 @@
</div>
</div>
{{$Root := .}}
<div class="column">
<div class="card">
<div class="card" id="notifications">
<header class="card-header has-background-warning">
<p class="card-header-title">Notifications</p>
</header>
<div class="card-content">
TBD.
<div class="columns">
<div class="column">
{{if gt .NavUnreadNotifications 0}}
{{.NavUnreadNotifications}} unread notification{{Pluralize64 .NavUnreadNotifications}}.
{{else}}
No unread notifications.
{{end}}
</div>
<div class="column is-narrow">
<a href="/me?intent=read-notifications" class="button is-link is-light is-small">
<span class="icon-text">
<span class="icon"><i class="fa fa-check"></i></span>
<span>Mark all as read</span>
</span>
</a>
</div>
</div>
<table class="table is-striped is-fullwidth is-hoverable">
<tbody>
{{range .Notifications}}
{{$Body := $Root.NotifMap.Get .ID}}
<tr>
<td class="nonshy-notification-row" data-notification-id="{{.ID}}">
<div class="columns">
<div class="column is-narrow has-text-centered">
{{if not .Read}}
<div class="mb-2 nonshy-notification-new">
<strong class="tag is-success">NEW!</strong>
</div>
{{end}}
<a href="/u/{{.User.Username}}">
<figure class="image is-48x48 is-inline-block">
{{if .User.ProfilePhoto.ID}}
<img src="{{PhotoURL .User.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</a>
</div>
<div class="column">
<div class="mb-1">
{{if eq .Type "like"}}
<span class="icon"><i class="fa fa-heart has-text-danger"></i></span>
<span>
<a href="/u/{{.User.Username}}"><strong>{{.User.Username}}</strong></a>
liked your
{{if eq .TableName "photos"}}
photo.
{{else if eq .TableName "users"}}
profile page.
{{else}}
{{.TableName}}.
{{end}}
</span>
{{else}}
{{.User.Username}} {{.Type}} {{.TableName}} {{.TableID}}
{{end}}
</div>
<!-- Photo caption? -->
{{if $Body.Photo}}
<div class="block">
<em>{{or $Body.Photo.Caption "No caption."}}</em>
</div>
{{end}}
<hr class="has-background-light mb-1">
<small title="{{.CreatedAt.Format "2006-01-02 15:04:05"}}">
{{SincePrettyCoarse .CreatedAt}} ago
</small>
</div>
<!-- Attached photo? -->
{{if $Body.PhotoID}}
<div class="column is-one-quarter">
<img src="{{PhotoURL $Body.Photo.Filename}}">
{{if $Body.Photo.Caption}}
<small>{{$Body.Photo.Caption}}</small>
{{else}}
<small><em>No caption.</em></small>
{{end}}
</div>
{{end}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
{{if .Pager.HasNext}}
<div class="has-text-centered">
<a href="{{.Request.URL.Path}}?page={{.Pager.Next}}" class="button">View older notifications</a>
</div>
{{end}}
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
// Notifications helper.
document.addEventListener('DOMContentLoaded', () => {
let busy = false;
// Bind to the notification table rows.
(document.querySelectorAll(".nonshy-notification-row") || []).forEach(node => {
node.addEventListener("click", (e) => {
if (busy) return;
let $newBadge = node.querySelector(".nonshy-notification-new"),
ID = node.dataset.notificationId;
$newBadge.style.display = "none";
return fetch("/v1/notifications/read", {
method: "POST",
mode: "same-origin",
cache: "no-cache",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
"id": parseInt(ID),
}),
})
.then((response) => response.json())
.then((data) => {
console.log(data);
}).catch(resp => {
window.alert(resp);
}).finally(() => {
busy = false;
})
});
});
});
</script>
{{end}}

View File

@ -121,16 +121,21 @@
</a>
</div>
<!-- <div class="column is-narrow has-text-centered">
<button type="button" class="button is-fullwidth">
<span class="icon-text">
<span class="icon">
<i class="fa fa-thumbs-up"></i>
</span>
<span>Like</span>
<!-- Like button -->
{{$Like := .LikeMap.Get .User.ID}}
<div class="column is-narrow has-text-centered">
<button type="button" class="button is-fullwidth nonshy-like-button"
data-table-name="users" data-table-id="{{.User.ID}}"
title="Like this profile">
<span class="icon{{if $Like.UserLikes}} has-text-danger{{end}}"><i class="fa fa-heart"></i></span>
<span class="nonshy-likes">
Like
{{if gt $Like.Count 0}}
({{$Like.Count}})
{{end}}
</span>
</button>
</div> -->
</div>
<div class="column is-narrow has-text-centered">
<form action="/users/block" method="POST">

View File

@ -151,16 +151,4 @@
</div>
</div>
<script type="text/javascript">
window.addEventListener("DOMContentLoaded", (event) => {
let $file = document.querySelector("#file"),
$fileName = document.querySelector("#fileName");
$file.addEventListener("change", function() {
let file = this.files[0];
$fileName.innerHTML = file.name;
});
});
</script>
{{end}}

View File

@ -108,7 +108,7 @@
<div class="navbar-end">
{{if .LoggedIn }}
<div id="navbar-user" class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="/me">
<a class="navbar-link" href="/me{{if .NavUnreadNotifications}}#notifications{{end}}">
<div class="columns is-mobile is-gapless">
<div class="column is-narrow">
<figure class="image is-24x24 mr-2">
@ -121,13 +121,22 @@
</div>
<div class="column">
{{.CurrentUser.Username}}
{{if .NavUnreadNotifications}}<span class="tag is-warning ml-1">{{.NavUnreadNotifications}}</span>{{end}}
{{if .NavAdminNotifications}}<span class="tag is-danger ml-1">{{.NavAdminNotifications}}</span>{{end}}
</div>
</div>
</a>
<div class="navbar-dropdown is-right is-hoverable">
<a class="navbar-item" href="/me">Dashboard</a>
<a class="navbar-item" href="/me{{if .NavUnreadNotifications}}#notifications{{end}}">
Dashboard
{{if .NavUnreadNotifications}}
<span class="tag is-warning ml-1">
<span class="icon"><i class="fa fa-bell"></i></span>
<span>{{.NavUnreadNotifications}}</span>
</span>
{{end}}
</a>
<a class="navbar-item" href="/u/{{.CurrentUser.Username}}">My Profile</a>
<a class="navbar-item" href="/photo/u/{{.CurrentUser.Username}}">My Photos</a>
<a class="navbar-item" href="/photo/upload">Upload Photo</a>
@ -180,6 +189,13 @@
</a>
{{end}}
{{if gt .NavUnreadNotifications 0}}
<a class="tag is-warning" href="/me#notifications">
<span class="icon"><i class="fa fa-bell"></i></span>
<span>{{.NavUnreadNotifications}}</span>
</a>
{{end}}
{{if gt .NavAdminNotifications 0}}
<a class="tag is-danger" href="/admin">
<span class="icon"><i class="fa fa-gavel"></i></span>
@ -246,7 +262,8 @@
</div>
</div>
<script type="text/javascript" src="/static/js/bulma.js"></script>
<script type="text/javascript" src="/static/js/bulma.js?build={{.BuildHash}}"></script>
<script type="text/javascript" src="/static/js/likes.js?build={{.BuildHash}}"></script>
</body>
</html>

View File

@ -5,8 +5,10 @@
<div class="hero-body">
<div class="container">
<h1 class="title">
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
<span>{{.Forum.Title}}</span>
<a href="/f/{{.Forum.Fragment}}" class="has-text-light">
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
<span>{{.Forum.Title}}</span>
</a>
</h1>
</div>
</div>
@ -28,10 +30,12 @@
</nav>
</div>
<div class="column is-narrow">
{{if or .CurrentUser.IsAdmin (not .Forum.Privileged) (eq .Forum.OwnerID .CurrentUser.ID)}}
<a href="/forum/post?to={{.Forum.Fragment}}" class="button is-primary">
<span class="icon"><i class="fa fa-plus"></i></span>
<span>New Thread</span>
</a>
{{end}}
</div>
</div>
</div>
@ -41,6 +45,13 @@
(page {{.Pager.Page}} of {{.Pager.Pages}}).
</p>
{{if .Forum.Privileged}}
<div class="block p-2 notification is-warning is-light">
<i class="fa fa-gavel mr-1"></i>
Only moderators may create new threads on this forum. You may be able to reply to threads here.
</div>
{{end}}
<div class="block p-2">
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
@ -85,6 +96,7 @@
<div class="column content">
<a href="/forum/thread/{{.ID}}" class="has-text-dark">
<h1 class="title pt-0">
{{if .Pinned}}<sup class="fa fa-thumbtack has-text-success mr-2 is-size-5" title="Pinned"></sup>{{end}}
{{or .Title "Untitled"}}
</h1>
{{TrimEllipses .Comment.Message 256}}
@ -92,7 +104,28 @@
<hr class="mb-1">
<div>
<em>Updated {{SincePrettyCoarse .UpdatedAt}} ago</em>
{{if .Pinned}}
<span class="tag is-success is-light mr-2">
<span class="icon"><i class="fa fa-thumbtack"></i></span>
<span>Pinned</span>
</span>
{{end}}
{{if .Explicit}}
<span class="tag is-danger is-light mr-2">
<span class="icon"><i class="fa fa-fire"></i></span>
<span>NSFW</span>
</span>
{{end}}
{{if .NoReply}}
<span class="tag is-warning is-light mr-2" title="This thread can not be replied to.">
<span class="icon"><i class="fa fa-ban"></i></span>
<span>No Reply</span>
</span>
{{end}}
<em title="{{.UpdatedAt.Format "2006-01-02 15:04:05"}}">Updated {{SincePrettyCoarse .UpdatedAt}} ago</em>
</div>
</div>
<div class="column is-narrow">

View File

@ -33,7 +33,10 @@
<h1 class="title">{{.Category}}</h1>
{{if eq (len .Forums) 0}}
<em>There are no forums under this category.</em>
<em>
There are no forums under this category.
{{if not $Root.CurrentUser.Explicit}}Your content filters (non-explicit) may be hiding some forums.{{end}}
</em>
{{else}}
{{range .Forums}}
{{$Stats := $Root.ForumMap.Get .ID}}
@ -63,7 +66,14 @@
{{if .Explicit}}
<span class="tag is-danger is-light">
<span class="icon"><i class="fa fa-fire"></i></span>
Explicit
<span>Explicit</span>
</span>
{{end}}
{{if .Privileged}}
<span class="tag is-warning is-light">
<span class="icon"><i class="fa fa-gavel"></i></span>
<span>Privileged</span>
</span>
{{end}}
</div>
@ -71,8 +81,18 @@
</div>
<div class="column">
<div class="box has-background-success-light">
<h2 class="subtitle">Latest Post</h2>
Hello world!
<h2 class="subtitle mb-0">Latest Post</h2>
{{if $Stats.RecentThread}}
<a href="/forum/thread/{{$Stats.RecentThread.ID}}">
<strong>{{$Stats.RecentThread.Title}}</strong>
</a>
<em>by {{$Stats.RecentThread.Comment.User.Username}}</em>
<div>
<small title="{{$Stats.RecentThread.UpdatedAt.Format "2006-01-02 15:04:05"}}">Last updated {{SincePrettyCoarse $Stats.RecentThread.UpdatedAt}} ago</small>
</div>
{{else}}
<em>No posts found.</em>
{{end}}
</div>
</div>
<div class="column is-3">
@ -97,7 +117,7 @@
{{end}}
</div>
</div>
<!-- <div class="column has-text-centered">
<div class="column has-text-centered">
<div class="box has-background-warning-light">
<p class="is-size-7">Users</p>
{{if $Stats}}
@ -106,7 +126,7 @@
err
{{end}}
</div>
</div> -->
</div>
</div>
</div>

View File

@ -81,19 +81,31 @@
</p>
</div>
{{if not .Thread}}
{{if or (not .Thread) .EditThreadSettings}}
<div class="field block">
<div class="label">Options</div>
{{if or .CurrentUser.IsAdmin (and .Forum (eq .Forum.OwnerID .CurrentUser.ID))}}
<div class="mb-1">
<label class="checkbox">
<input type="checkbox"
name="pinned"
value="true"
{{if .IsPinned}}checked{{end}}>
Pinned to top
</label>
</div>
{{end}}
{{if or .CurrentUser.Explicit .IsExplicit}}
<div class="mb-1">
<label class="checkbox">
<input type="checkbox"
name="explicit"
value="true"
{{if .Explicit}}checked{{end}}>
{{if .IsExplicit}}checked{{end}}>
Mark as Explicit (NSFW)
</label>
</div>
{{end}}
{{if .CurrentUser.IsAdmin}}
<div>
@ -101,7 +113,7 @@
<input type="checkbox"
name="noreply"
value="true"
{{if .Explicit}}checked{{end}}>
{{if .IsNoReply}}checked{{end}}>
No replies allowed <i class="fa fa-gavel ml-1"></i>
</label>
</div>

View File

@ -5,8 +5,10 @@
<div class="hero-body">
<div class="container">
<h1 class="title">
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
<span>{{.Forum.Title}}</span>
<a href="/f/{{.Forum.Fragment}}" class="has-text-light">
<span class="icon mr-4"><i class="fa fa-comments"></i></span>
<span>{{.Forum.Title}}</span>
</a>
</h1>
</div>
</div>
@ -29,15 +31,45 @@
</nav>
</div>
<div class="column is-narrow">
{{if not .Thread.NoReply}}
<a href="/forum/post?to={{.Forum.Fragment}}&thread={{.Thread.ID}}" class="button is-link">
<span class="icon"><i class="fa fa-reply"></i></span>
<span>Add Reply</span>
</a>
{{end}}
</div>
</div>
</div>
<h1 class="title px-4">{{or .Thread.Title "Untitled Thread"}}</h1>
<h1 class="title px-4">
{{if .Thread.Pinned}}<sup class="fa fa-thumbtack has-text-success mr-2 is-size-5" title="Pinned"></sup>{{end}}
{{or .Thread.Title "Untitled Thread"}}
</h1>
<div class="px-4">
{{if .Thread.Pinned}}
<span class="tag is-success is-light mr-2">
<span class="icon"><i class="fa fa-thumbtack"></i></span>
<span>Pinned</span>
</span>
{{end}}
{{if .Thread.Explicit}}
<span class="tag is-danger is-light mr-2">
<span class="icon"><i class="fa fa-fire"></i></span>
<span>NSFW</span>
</span>
{{end}}
{{if .Thread.NoReply}}
<span class="tag is-warning is-light mr-2" title="This thread can not be replied to.">
<span class="icon"><i class="fa fa-ban"></i></span>
<span>No Reply</span>
</span>
{{end}}
<em title="{{.Thread.UpdatedAt.Format "2006-01-02 15:04:05"}}">Updated {{SincePrettyCoarse .Thread.UpdatedAt}} ago</em>
</div>
<p class="block p-4">
Found <strong>{{.Pager.Total}}</strong> post{{Pluralize64 .Pager.Total}} on this thread
@ -97,18 +129,21 @@
<hr class="has-background-grey mb-2">
<div class="columns is-mobile is-multiline is-size-7">
<div class="columns is-mobile is-multiline is-size-7 mb-0">
<div class="column is-narrow">
<span title="{{.CreatedAt.Format "2006-01-02 15:04:05"}}">
{{SincePrettyCoarse .CreatedAt}} ago
</span>
</div>
<div class="column is-narrow">
<a href="/contact?intent=report&subject=report.comment&id={{.ID}}" class="has-text-dark">
<span class="icon"><i class="fa fa-flag"></i></span>
<span>Report</span>
</a>
</div>
{{if not $Root.Thread.NoReply}}
<div class="column is-narrow">
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}&quote={{.ID}}" class="has-text-dark">
<span class="icon"><i class="fa fa-quote-right"></i></span>
@ -121,6 +156,7 @@
<span>Reply</span>
</a>
</div>
{{end}}
{{if or $Root.CurrentUser.IsAdmin (eq $Root.CurrentUser.ID .User.ID)}}
<div class="column is-narrow">
@ -137,17 +173,33 @@
</div>
{{end}}
</div>
{{if $Root.CurrentUser.IsAdmin}}
<div>
<span class="tag is-primary is-light">
<span class="icon"><i class="fa fa-database"></i></span>
<span>ID: {{.ID}}</span>
</span>
</div>
{{end}}
</div>
</div>
</div>
{{end}}
</div>
<div class="block p-2 has-text-right">
<a href="/forum/post?to={{.Forum.Fragment}}&thread={{.Thread.ID}}" class="button is-link">
<span class="icon"><i class="fa fa-reply"></i></span>
<span>Add Reply</span>
</a>
</div>
{{if .Thread.NoReply}}
<div class="block notification is-warning is-light">
<i class="fa fa-ban pr-2"></i>
This thread is not accepting any new replies.
</div>
{{else}}
<div class="block p-2 has-text-right">
<a href="/forum/post?to={{.Forum.Fragment}}&thread={{.Thread.ID}}" class="button is-link">
<span class="icon"><i class="fa fa-reply"></i></span>
<span>Add Reply</span>
</a>
</div>
{{end}}
{{end}}

View File

@ -0,0 +1,2 @@
{{define "like-button"}}
{{end}}

View File

@ -259,6 +259,22 @@
{{else}}<em>No caption</em>{{end}}
{{template "card-body" .}}
<!-- Like button -->
<div class="mt-4">
{{$Like := $Root.LikeMap.Get .ID}}
<button type="button" class="button is-small nonshy-like-button"
data-table-name="photos" data-table-id="{{.ID}}"
title="Like">
<span class="icon{{if $Like.UserLikes}} has-text-danger{{end}}"><i class="fa fa-heart"></i></span>
<span class="nonshy-likes">
Like
{{if gt $Like.Count 0}}
({{$Like.Count}})
{{end}}
</span>
</button>
</div>
</div>
<footer class="card-footer">
@ -340,6 +356,22 @@
{{else}}<em>No caption</em>{{end}}
{{template "card-body" .}}
<!-- Like button -->
<div class="mt-4">
{{$Like := $Root.LikeMap.Get .ID}}
<button type="button" class="button is-small nonshy-like-button"
data-table-name="photos" data-table-id="{{.ID}}"
title="Like">
<span class="icon{{if $Like.UserLikes}} has-text-danger{{end}}"><i class="fa fa-heart"></i></span>
<span class="nonshy-likes">
Like
{{if gt $Like.Count 0}}
({{$Like.Count}})
{{end}}
</span>
</button>
</div>
</div>
<footer class="card-footer">