Friend Requests and User Search

This commit is contained in:
Noah 2022-08-13 22:44:57 -07:00
parent c8f6cf2e4d
commit 4ad7e00c44
18 changed files with 869 additions and 17 deletions

12
pkg/config/page_sizes.go Normal file
View File

@ -0,0 +1,12 @@
package config
// Pagination sizes per page.
var (
PageSizeMemberSearch = 60
PageSizeFriends = 12
PageSizeAdminCertification = 20
PageSizeSiteGallery = 18
PageSizeUserGallery = 18
PageSizeInboxList = 20 // sidebar list
PageSizeInboxThread = 20 // conversation view
)

View File

@ -5,6 +5,7 @@ import (
"regexp"
"git.kirsle.net/apps/gosocial/pkg/models"
"git.kirsle.net/apps/gosocial/pkg/session"
"git.kirsle.net/apps/gosocial/pkg/templates"
)
@ -21,6 +22,14 @@ func Profile() http.HandlerFunc {
username = m[1]
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get CurrentUser: %s", err)
templates.Redirect(w, "/")
return
}
// Find this user.
user, err := models.FindUser(username)
if err != nil {
@ -29,7 +38,8 @@ func Profile() http.HandlerFunc {
}
vars := map[string]interface{}{
"User": user,
"User": user,
"IsFriend": models.FriendStatus(currentUser.ID, user.ID),
}
if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -0,0 +1,64 @@
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"
)
// Search controller.
func Search() http.HandlerFunc {
tmpl := templates.Must("account/search.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Search filters.
var (
isCertified = r.FormValue("certified")
username = r.FormValue("username") // email or username
gender = r.FormValue("gender")
orientation = r.FormValue("orientation")
maritalStatus = r.FormValue("marital_status")
)
// Default
if isCertified == "" {
isCertified = "true"
}
pager := &models.Pagination{
PerPage: config.PageSizeMemberSearch,
}
pager.ParsePage(r)
users, err := models.SearchUsers(&models.UserSearch{
EmailOrUsername: username,
Gender: gender,
Orientation: orientation,
MaritalStatus: maritalStatus,
Certified: isCertified == "true",
}, pager)
if err != nil {
session.FlashError(w, r, "Couldn't search users: %s", err)
}
var vars = map[string]interface{}{
"Users": users,
"Pager": pager,
"Enum": config.ProfileEnums,
// Search filter values.
"Certified": isCertified,
"Gender": gender,
"Orientation": orientation,
"MaritalStatus": maritalStatus,
"EmailOrUsername": username,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -0,0 +1,48 @@
package friend
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"
)
// Friends list and pending friend request endpoint.
func Friends() http.HandlerFunc {
tmpl := templates.Must("friend/friends.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
isRequests := r.FormValue("view") == "requests"
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
templates.Redirect(w, "/")
return
}
// Get our friends.
pager := &models.Pagination{
PerPage: config.PageSizeFriends,
Sort: "updated_at desc",
}
pager.ParsePage(r)
friends, err := models.PaginateFriends(currentUser.ID, isRequests, pager)
if err != nil {
session.FlashError(w, r, "Couldn't paginate friends: %s", err)
templates.Redirect(w, "/")
return
}
var vars = map[string]interface{}{
"IsRequests": isRequests,
"Friends": friends,
"Pager": pager,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -0,0 +1,89 @@
package friend
import (
"fmt"
"net/http"
"strings"
"git.kirsle.net/apps/gosocial/pkg/models"
"git.kirsle.net/apps/gosocial/pkg/session"
"git.kirsle.net/apps/gosocial/pkg/templates"
)
// AddFriend controller to send a friend request.
func AddFriend() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// POST only.
if r.Method != http.MethodPost {
session.FlashError(w, r, "Unacceptable Request Method")
templates.Redirect(w, "/")
return
}
// Form fields
var (
username = strings.ToLower(r.PostFormValue("username"))
verdict = r.PostFormValue("verdict")
)
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get CurrentUser: %s", err)
templates.Redirect(w, "/")
return
}
// Get the target user.
user, err := models.FindUser(username)
if err != nil {
session.FlashError(w, r, "User Not Found")
templates.Redirect(w, "/")
return
}
// Can't friend yourself.
if currentUser.ID == user.ID {
session.FlashError(w, r, "You can't send a friend request to yourself!")
templates.Redirect(w, "/u/"+username)
return
}
// Are we adding, or rejecting+removing?
if verdict == "reject" || verdict == "remove" {
err := models.RemoveFriend(currentUser.ID, user.ID)
if err != nil {
session.FlashError(w, r, "Failed to remove friend: %s", err)
templates.Redirect(w, "/u/"+username)
return
}
var message string
if verdict == "reject" {
message = fmt.Sprintf("Friend request from %s has been rejected.", username)
} else {
message = fmt.Sprintf("Removed friendship with %s.", username)
}
session.Flash(w, r, message)
if verdict == "reject" {
templates.Redirect(w, "/friends?view=requests")
}
templates.Redirect(w, "/friends")
} else {
// Post the friend request.
if err := models.AddFriend(currentUser.ID, user.ID); err != nil {
session.FlashError(w, r, "Couldn't send friend request: %s.", err)
} else {
if verdict == "approve" {
session.Flash(w, r, "You accepted the friend request from %s!", username)
templates.Redirect(w, "/friends?view=requests")
return
}
session.Flash(w, r, "Friend request sent!")
}
}
templates.Redirect(w, "/u/"+username)
})
}

View File

@ -5,6 +5,7 @@ import (
"regexp"
"strconv"
"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"
@ -58,7 +59,7 @@ func Inbox() http.HandlerFunc {
// Get the full chat thread (paginated).
threadPager = &models.Pagination{
PerPage: 5,
PerPage: config.PageSizeInboxThread,
Sort: "created_at desc",
}
threadPager.ParsePage(r)
@ -84,7 +85,7 @@ func Inbox() http.HandlerFunc {
// Get the inbox list of messages.
pager := &models.Pagination{
Page: 1,
PerPage: 5,
PerPage: config.PageSizeInboxList,
Sort: "created_at desc",
}
if viewThread == nil {

View File

@ -271,7 +271,7 @@ func AdminCertification() http.HandlerFunc {
// Get the pending photos.
pager := &models.Pagination{
Page: 1,
PerPage: 20,
PerPage: config.PageSizeAdminCertification,
Sort: "updated_at desc",
}
pager.ParsePage(r)

View File

@ -3,6 +3,7 @@ package photo
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"
@ -29,7 +30,7 @@ func SiteGallery() http.HandlerFunc {
// Get the page of photos.
pager := &models.Pagination{
Page: 1,
PerPage: 8,
PerPage: config.PageSizeSiteGallery,
Sort: "created_at desc",
}
pager.ParsePage(r)

View File

@ -4,6 +4,7 @@ import (
"net/http"
"regexp"
"git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/models"
"git.kirsle.net/apps/gosocial/pkg/session"
@ -49,6 +50,8 @@ func UserPhotos() http.HandlerFunc {
visibility := []models.PhotoVisibility{models.PhotoPublic}
if isOwnPhotos || currentUser.IsAdmin {
visibility = append(visibility, models.PhotoFriends, models.PhotoPrivate)
} else if models.AreFriends(user.ID, currentUser.ID) {
visibility = append(visibility, models.PhotoFriends)
}
// Explicit photo filter?
@ -60,7 +63,7 @@ func UserPhotos() http.HandlerFunc {
// Get the page of photos.
pager := &models.Pagination{
Page: 1,
PerPage: 8,
PerPage: config.PageSizeUserGallery,
Sort: "created_at desc",
}
pager.ParsePage(r)

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

@ -0,0 +1,175 @@
package models
import (
"errors"
"time"
"gorm.io/gorm"
)
// Friend table.
type Friend struct {
ID uint64 `gorm:"primaryKey"`
SourceUserID uint64 `gorm:"index"`
TargetUserID uint64 `gorm:"index"`
Approved bool `gorm:"index"`
CreatedAt time.Time
UpdatedAt time.Time
}
// AddFriend sends a friend request or accepts one if there was already a pending one.
func AddFriend(sourceUserID, targetUserID uint64) error {
// Did we already send a friend request?
f := &Friend{}
forward := DB.Where(
"source_user_id = ? AND target_user_id = ?",
sourceUserID, targetUserID,
).First(&f).Error
// Is there a reverse friend request pending?
rev := &Friend{}
reverse := DB.Where(
"source_user_id = ? AND target_user_id = ?",
targetUserID, sourceUserID,
).First(&rev).Error
// If the reverse exists (requested us) but not the forward, this completes the friendship.
if reverse == nil && forward != nil {
// Approve the reverse.
rev.Approved = true
rev.Save()
// Add the matching forward.
f = &Friend{
SourceUserID: sourceUserID,
TargetUserID: targetUserID,
Approved: true,
}
return DB.Create(f).Error
}
// If the forward already existed, error.
if forward == nil {
if f.Approved {
return errors.New("you are already friends")
}
return errors.New("a friend request had already been sent")
}
// Create the pending forward request.
f = &Friend{
SourceUserID: sourceUserID,
TargetUserID: targetUserID,
Approved: false,
}
return DB.Create(f).Error
}
// AreFriends quickly checks if two user IDs are friends.
func AreFriends(sourceUserID, targetUserID uint64) bool {
f := &Friend{}
DB.Where(
"source_user_id = ? AND target_user_id = ?",
sourceUserID, targetUserID,
).First(&f)
return f.Approved
}
// FriendStatus returns an indicator of friendship status: "none", "pending", "approved"
func FriendStatus(sourceUserID, targetUserID uint64) string {
f := &Friend{}
result := DB.Where(
"source_user_id = ? AND target_user_id = ?",
sourceUserID, targetUserID,
).First(&f)
if result.Error == nil {
if f.Approved {
return "approved"
}
return "pending"
}
return "none"
}
// CountFriendRequests gets a count of pending requests for the user.
func CountFriendRequests(userID uint64) (int64, error) {
var count int64
result := DB.Where(
"target_user_id = ? AND approved = ?",
userID,
false,
).Model(&Friend{}).Count(&count)
return count, result.Error
}
// PaginateFriends gets a page of friends (or pending friend requests) as User objects ordered
// by friendship date.
func PaginateFriends(userID uint64, requests bool, pager *Pagination) ([]*User, error) {
// We paginate over the Friend table.
var (
fs = []*Friend{}
userIDs = []uint64{}
query *gorm.DB
)
if requests {
query = DB.Where(
"target_user_id = ? AND approved = ?",
userID,
false,
)
} else {
query = DB.Where(
"source_user_id = ? AND approved = ?",
userID,
true,
)
}
query = query.Order(pager.Sort)
query.Model(&Friend{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
if result.Error != nil {
return nil, result.Error
}
// Now of these friends get their User objects.
for _, friend := range fs {
if requests {
userIDs = append(userIDs, friend.SourceUserID)
} else {
userIDs = append(userIDs, friend.TargetUserID)
}
}
return GetUsers(userIDs)
}
// GetFriendRequests returns all pending friend requests for a user.
func GetFriendRequests(userID uint64) ([]*Friend, error) {
var fs = []*Friend{}
result := DB.Where(
"target_user_id = ? AND approved = ?",
userID,
false,
).Find(&fs)
return fs, result.Error
}
// RemoveFriend severs a friend connection both directions, used when
// rejecting a request or removing a friend.
func RemoveFriend(sourceUserID, targetUserID uint64) error {
result := DB.Where(
"(source_user_id = ? AND target_user_id = ?) OR "+
"(target_user_id = ? AND source_user_id = ?)",
sourceUserID, targetUserID,
sourceUserID, targetUserID,
).Delete(&Friend{})
return result.Error
}
// Save photo.
func (f *Friend) Save() error {
result := DB.Save(f)
return result.Error
}

View File

@ -13,4 +13,5 @@ func AutoMigrate() {
DB.AutoMigrate(&Photo{})
DB.AutoMigrate(&CertificationPhoto{})
DB.AutoMigrate(&Message{})
DB.AutoMigrate(&Friend{})
}

View File

@ -79,6 +79,24 @@ func GetUser(userId uint64) (*User, error) {
return user, result.Error
}
// GetUsers queries for multiple user IDs and returns users in the same order.
func GetUsers(userIDs []uint64) ([]*User, error) {
userMap, err := MapUsers(userIDs)
if err != nil {
return nil, err
}
// Re-order them per the original sequence.
var users = []*User{}
for _, uid := range userIDs {
if user, ok := userMap[uid]; ok {
users = append(users, user)
}
}
return users, nil
}
// FindUser by username or email.
func FindUser(username string) (*User, error) {
if username == "" {
@ -94,6 +112,78 @@ func FindUser(username string) (*User, error) {
return u, result.Error
}
// UserSearch config.
type UserSearch struct {
EmailOrUsername string
Gender string
Orientation string
MaritalStatus string
Certified bool
}
// SearchUsers
func SearchUsers(search *UserSearch, pager *Pagination) ([]*User, error) {
if search == nil {
search = &UserSearch{}
}
var (
users = []*User{}
query *gorm.DB
wheres = []string{}
placeholders = []interface{}{}
)
if search.EmailOrUsername != "" {
ilike := "%" + strings.TrimSpace(strings.ToLower(search.EmailOrUsername)) + "%"
wheres = append(wheres, "(email LIKE ? OR username LIKE ?)")
placeholders = append(placeholders, ilike, ilike)
}
if search.Gender != "" {
wheres = append(wheres, `
EXISTS (
SELECT 1 FROM profile_fields
WHERE user_id = users.id AND name = ? AND value = ?
)
`)
placeholders = append(placeholders, "gender", search.Gender)
}
if search.Orientation != "" {
wheres = append(wheres, `
EXISTS (
SELECT 1 FROM profile_fields
WHERE user_id = users.id AND name = ? AND value = ?
)
`)
placeholders = append(placeholders, "orientation", search.Orientation)
}
if search.MaritalStatus != "" {
wheres = append(wheres, `
EXISTS (
SELECT 1 FROM profile_fields
WHERE user_id = users.id AND name = ? AND value = ?
)
`)
placeholders = append(placeholders, "marital_status", search.MaritalStatus)
}
if search.Certified {
wheres = append(wheres, "certified = ?")
placeholders = append(placeholders, search.Certified)
}
query = (&User{}).Preload().Where(
strings.Join(wheres, " AND "),
placeholders...,
)
query.Model(&User{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&users)
return users, result.Error
}
// UserMap helps map a set of users to look up by ID.
type UserMap map[uint64]*User

View File

@ -8,6 +8,7 @@ import (
"git.kirsle.net/apps/gosocial/pkg/controller/account"
"git.kirsle.net/apps/gosocial/pkg/controller/admin"
"git.kirsle.net/apps/gosocial/pkg/controller/api"
"git.kirsle.net/apps/gosocial/pkg/controller/friend"
"git.kirsle.net/apps/gosocial/pkg/controller/inbox"
"git.kirsle.net/apps/gosocial/pkg/controller/index"
"git.kirsle.net/apps/gosocial/pkg/controller/photo"
@ -35,9 +36,12 @@ func New() http.Handler {
mux.Handle("/messages", middleware.LoginRequired(inbox.Inbox()))
mux.Handle("/messages/read/", middleware.LoginRequired(inbox.Inbox()))
mux.Handle("/messages/compose", middleware.LoginRequired(inbox.Compose()))
mux.Handle("/friends", middleware.LoginRequired(friend.Friends()))
mux.Handle("/friends/add", middleware.LoginRequired(friend.AddFriend()))
// Certification Required. Pages that only full (verified) members can access.
mux.Handle("/photo/gallery", middleware.CertRequired(photo.SiteGallery()))
mux.Handle("/members", middleware.CertRequired(account.Search()))
// Admin endpoints.
mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard()))

View File

@ -29,6 +29,7 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
m["LoggedIn"] = false
m["CurrentUser"] = nil
m["NavUnreadMessages"] = 0
m["NavFriendRequests"] = 0
if r == nil {
return
@ -44,5 +45,12 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
} else {
log.Error("MergeUserVars: couldn't CountUnreadMessages for %d: %s", user.ID, err)
}
// Get friend request count.
if count, err := models.CountFriendRequests(user.ID); err == nil {
m["NavFriendRequests"] = count
} else {
log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err)
}
}
}

View File

@ -87,16 +87,27 @@
<div class="level">
<div class="level-item">
<div class="field has-addons">
<p class="control">
<button type="button" class="button">
<span class="icon-text">
<span class="icon">
<i class="fa fa-plus"></i>
<form action="/friends/add" method="POST">
{{InputCSRF}}
<input type="hidden" name="username" value="{{.User.Username}}">
<p class="control">
<button type="submit" class="button"
{{if not (eq .IsFriend "none")}}title="Friendship {{.IsFriend}}"{{end}}>
<span class="icon-text">
<span class="icon">
{{if eq .IsFriend "approved"}}
<i class="fa fa-check has-text-success"></i>
{{else if eq .IsFriend "pending"}}
<i class="fa fa-spinner fa-spin"></i>
{{else}}
<i class="fa fa-plus"></i>
{{end}}
</span>
<span>Friend{{if eq .IsFriend "approved"}}s{{end}}</span>
</span>
<span>Friend</span>
</span>
</button>
</p>
</button>
</p>
</form>
<p class="control">
<a href="/messages/compose?to={{.User.Username}}" class="button">

View File

@ -0,0 +1,198 @@
{{define "title"}}Friends{{end}}
{{define "content"}}
<div class="container">
{{$Root := .}}
<section class="hero is-link is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">People</h1>
<h2 class="subtitle">Explore</h2>
</div>
</div>
</section>
<form action="/members" method="GET">
<div class="p-4">
<div class="columns">
<div class="column">
Found {{.Pager.Total}} user{{Pluralize64 .Pager.Total}}
(page {{.Pager.Page}} of {{.Pager.Pages}}).
</div>
<div class="column is-narrow">
<button type="submit"
class="button ml-6"
name="page"
value="{{.Pager.Previous}}"
{{if not .Pager.HasPrevious}}disabled{{end}}>Previous</button>
<button type="submit"
class="button button-primary"
name="page"
value="{{.Pager.Next}}"
{{if not .Pager.HasNext}}disabled{{end}}>Next page</button>
</div>
</div>
<div class="block">
<div class="card">
<header class="card-header">
<p class="card-header-title">
Search Filters
</p>
</header>
<div class="card-content">
<div class="columns is-multiline">
<div class="column">
<div class="field">
<label class="label">Certified:</label>
<div class="select is-fullwidth">
<select id="certified" name="certified">
<option value="true">Only certified users</option>
<option value="false"{{if eq $Root.Certified "false"}} selected{{end}}>Show all users</option>
</select>
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Email or username:</label>
<input type="text" class="input"
name="username"
autocomplete="off"
value="{{$Root.EmailOrUsername}}">
</div>
</div>
<div class="column">
<div class="field">
<label class="label" for="gender">Gender:</label>
<div class="select is-fullwidth">
<select id="gender" name="gender">
<option value=""></option>
{{range .Enum.Gender}}
<option value="{{.}}"{{if eq $Root.Gender .}} selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label" for="orientation">Orientation:</label>
<div class="select is-fullwidth">
<select id="orientation" name="orientation">
<option value=""></option>
{{range .Enum.Orientation}}
<option value="{{.}}"{{if eq $Root.Orientation .}} selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label" for="marital_status">Relationship:</label>
<div class="select is-fullwidth">
<select id="marital_status" name="marital_status">
<option value=""></option>
{{range .Enum.MaritalStatus}}
<option value="{{.}}"{{if eq $Root.MaritalStatus .}} selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
</div>
</div>
</div>
<div class="has-text-centered">
<a href="/members" class="button">Reset</a>
<button type="submit" class="button is-success">
<span>Search</span>
<span class="icon"><i class="fa fa-search"></i></span>
</button>
</div>
</div>
</div>
</div>
<div class="columns is-multiline">
{{range .Users}}
<div class="column is-half-tablet is-one-third-desktop">
<div class="card">
<div class="card-content">
<div class="media block">
<div class="media-left">
<figure class="image is-64x64">
<a href="/u/{{.Username}}" class="has-text-dark">
{{if .ProfilePhoto.ID}}
<img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</a>
</figure>
</div>
<div class="media-content">
<p class="title is-4">
<a href="/u/{{.Username}}" class="has-text-dark">{{or .Name "(no name)"}}</a>
</p>
<p class="subtitle is-6 mb-2">
<span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{.Username}}">{{.Username}}</a>
{{if not .Certified}}
<span class="has-text-danger">
<span class="icon"><i class="fa fa-certificate"></i></span>
<span>Not Certified!</span>
</span>
{{end}}
{{if .IsAdmin}}
<span class="has-text-danger">
<span class="icon"><i class="fa fa-gavel"></i></span>
<span>Admin</span>
</span>
{{end}}
</p>
{{if .GetProfileField "city"}}
<p class="subtitle is-6 mb-2">
{{.GetProfileField "city"}}
</p>
{{end}}
<p class="subtitle is-7 mb-2">
{{if not .Birthdate.IsZero }}
<span class="mr-2">{{ComputeAge .Birthdate}}yo</span>
{{end}}
{{if .GetProfileField "gender"}}
<span class="mr-2">{{.GetProfileField "gender"}}</span>
{{end}}
{{if .GetProfileField "pronouns"}}
<span class="mr-2">{{.GetProfileField "pronouns"}}</span>
{{end}}
{{if .GetProfileField "orientation"}}
<span class="mr-2">{{.GetProfileField "orientation"}}</span>
{{end}}
</p>
</div>
</div><!-- media-block -->
</div>
</div>
</div>
{{end}}<!-- range .Friends -->
</div>
</div>
</form>
</div>
{{end}}

View File

@ -56,13 +56,13 @@
<a class="navbar-item" href="/friends">
<span class="icon"><i class="fa fa-user-group"></i></span>
<span>Friends</span>
<!-- <span class="tag is-warning">42</span> -->
{{if .NavFriendRequests}}<span class="tag is-warning ml-1">{{.NavFriendRequests}}</span>{{end}}
</a>
<a class="navbar-item" href="/messages">
<span class="icon"><i class="fa fa-envelope"></i></span>
<span>Messages</span>
{{if .NavUnreadMessages}}<span class="tag is-warning">{{.NavUnreadMessages}}</span>{{end}}
{{if .NavUnreadMessages}}<span class="tag is-warning ml-1">{{.NavUnreadMessages}}</span>{{end}}
</a>
{{end}}
@ -72,6 +72,10 @@
</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="/members">
<span class="icon"><i class="fa fa-people-group"></i></span>
<span>People</span>
</a>
<a class="navbar-item" href="/about">
About
</a>

View File

@ -0,0 +1,133 @@
{{define "title"}}Friends{{end}}
{{define "content"}}
<div class="container">
{{$Root := .}}
<section class="hero is-link is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">Friends</h1>
</div>
</div>
</section>
<div class="p-4 is-text-centered">
<div class="level">
<div class="level-item">
<div class="tabs is-toggle">
<ul>
<li{{if not .IsRequests}} class="is-active"{{end}}>
<a href="/friends">My Friends</a>
</li>
<li{{if .IsRequests}} class="is-active"{{end}}>
<a href="/friends?view=requests">
Requests
{{if .NavFriendRequests}}<span class="tag is-warning ml-2">{{.NavFriendRequests}}</span>{{end}}
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="p-4">
<div class="block">
You have {{.Pager.Total}} friend{{if .IsRequests}} request{{end}}{{Pluralize64 .Pager.Total}}
(page {{.Pager.Page}} of {{.Pager.Pages}}).
</div>
<div class="block">
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
href="{{.Request.URL.Path}}?{{if .IsRequests}}view=requests&{{end}}page={{.Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?{{if .IsRequests}}view=requests&{{end}}page={{.Pager.Next}}">Next page</a>
<ul class="pagination-list">
{{$Root := .}}
{{range .Pager.Iter}}
<li>
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
aria-label="Page {{.Page}}"
href="{{$Root.Request.URL.Path}}?{{if $Root.IsRequests}}view=requests&{{end}}page={{.Page}}">
{{.Page}}
</a>
</li>
{{end}}
</ul>
</nav>
</div>
<div class="columns is-multiline">
{{range .Friends}}
<div class="column is-half-tablet is-one-third-desktop">
<form action="/friends/add" method="POST">
{{InputCSRF}}
<input type="hidden" name="username" value="{{.Username}}">
<div class="card">
<div class="card-content">
<div class="media block">
<div class="media-left">
<figure class="image is-64x64">
{{if .ProfilePhoto.ID}}
<img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</div>
<div class="media-content">
<p class="title is-4">{{or .Name "(no name)"}}</p>
<p class="subtitle is-6">
<span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{.Username}}">{{.Username}}</a>
{{if not .Certified}}
<span class="has-text-danger">
<span class="icon"><i class="fa fa-certificate"></i></span>
<span>Not Certified!</span>
</span>
{{end}}
{{if .IsAdmin}}
<span class="has-text-danger">
<span class="icon"><i class="fa fa-gavel"></i></span>
<span>Admin</span>
</span>
{{end}}
</p>
</div>
</div>
</div>
{{if $Root.IsRequests}}
<footer class="card-footer">
<button type="submit" name="verdict" value="approve" class="card-footer-item button is-success">
<span class="icon"><i class="fa fa-check"></i></span>
<span>Approve</span>
</button>
<button type="submit" name="verdict" value="reject" class="card-footer-item button is-danger">
<span class="icon"><i class="fa fa-xmark"></i></span>
<span>Reject</span>
</button>
</footer>
{{else}}
<footer class="card-footer">
<button type="submit" name="verdict" value="remove" class="card-footer-item button is-danger"
onclick="return confirm('Are you sure you want to remove this friendship?')">
<span class="icon"><i class="fa fa-xmark"></i></span>
<span>Remove</span>
</button>
</footer>
{{end}}
</div>
</form>
</div>
{{end}}<!-- range .Friends -->
</div>
</div>
</div>
{{end}}