See who has "Liked" something
This commit is contained in:
parent
3543dd3e42
commit
de30f5e952
|
@ -23,4 +23,5 @@ var (
|
|||
PageSizeThreadList = 20 // 20 threads per board, 20 posts per thread
|
||||
PageSizeForumAdmin = 20
|
||||
PageSizeDashboardNotifications = 50
|
||||
PageSizeLikeList = 12 // number of likes to show in popup modal
|
||||
)
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"net/url"
|
||||
"regexp"
|
||||
|
||||
"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"
|
||||
|
@ -105,12 +106,24 @@ func Profile() http.HandlerFunc {
|
|||
// Get Likes for this profile.
|
||||
likeMap := models.MapLikes(currentUser, "users", []uint64{user.ID})
|
||||
|
||||
// Get the summary of WHO liked this picture.
|
||||
likeExample, likeRemainder, err := models.WhoLikes("users", user.ID)
|
||||
if err != nil {
|
||||
log.Error("WhoLikes(user %d): %s", user.ID, err)
|
||||
}
|
||||
|
||||
vars := map[string]interface{}{
|
||||
"User": user,
|
||||
"LikeMap": likeMap,
|
||||
"IsFriend": isFriend,
|
||||
"IsPrivate": isPrivate,
|
||||
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
|
||||
|
||||
// Details on who likes the photo.
|
||||
"LikeExample": likeExample,
|
||||
"LikeRemainder": likeRemainder,
|
||||
"LikeTableName": "users",
|
||||
"LikeTableID": user.ID,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
|
|
|
@ -3,13 +3,15 @@ package api
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
)
|
||||
|
||||
// Likes API.
|
||||
// Likes API posts a new like on something.
|
||||
func Likes() http.HandlerFunc {
|
||||
// Request JSON schema.
|
||||
type Request struct {
|
||||
|
@ -70,11 +72,11 @@ func Likes() http.HandlerFunc {
|
|||
if user, err := models.GetUser(photo.UserID); err == nil {
|
||||
// Admin safety check: in case the admin clicked 'Like' on a friends-only or private
|
||||
// picture they shouldn't have been expected to see, do not log a like.
|
||||
if currentUser.IsAdmin {
|
||||
if currentUser.IsAdmin && currentUser.ID != user.ID {
|
||||
if (photo.Visibility == models.PhotoFriends && !models.AreFriends(user.ID, currentUser.ID)) ||
|
||||
(photo.Visibility == models.PhotoPrivate && !models.IsPrivateUnlocked(user.ID, currentUser.ID)) {
|
||||
SendJSON(w, http.StatusForbidden, Response{
|
||||
Error: fmt.Sprintf("You are not allowed to like that photo."),
|
||||
Error: "You are not allowed to like that photo.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
@ -155,3 +157,92 @@ func Likes() http.HandlerFunc {
|
|||
})
|
||||
})
|
||||
}
|
||||
|
||||
// WhoLikes API checks who liked something.
|
||||
func WhoLikes() http.HandlerFunc {
|
||||
// Response JSON schema.
|
||||
type Liker struct {
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
Relationship models.UserRelationship `json:"relationship"`
|
||||
}
|
||||
type Response struct {
|
||||
OK bool `json:"OK"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Likes []Liker `json:"likes,omitempty"`
|
||||
Pager *models.Pagination `json:"pager,omitempty"`
|
||||
Pages int `json:"pages,omitempty"`
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
SendJSON(w, http.StatusNotAcceptable, Response{
|
||||
Error: "GET method only",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request parameters.
|
||||
var (
|
||||
tableName = r.FormValue("table_name")
|
||||
tableID, _ = strconv.Atoi(r.FormValue("table_id"))
|
||||
page, _ = strconv.Atoi(r.FormValue("page"))
|
||||
)
|
||||
if tableName == "" {
|
||||
SendJSON(w, http.StatusBadRequest, Response{
|
||||
Error: "Missing required table_name",
|
||||
})
|
||||
return
|
||||
} else if tableID == 0 {
|
||||
SendJSON(w, http.StatusBadRequest, Response{
|
||||
Error: "Missing required table_id",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
// Get the current user.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
SendJSON(w, http.StatusBadRequest, Response{
|
||||
Error: "Couldn't get current user!",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get a page of users who've liked this.
|
||||
var pager = &models.Pagination{
|
||||
Page: page,
|
||||
PerPage: config.PageSizeLikeList,
|
||||
Sort: "created_at desc",
|
||||
}
|
||||
users, err := models.PaginateLikes(currentUser, tableName, uint64(tableID), pager)
|
||||
if err != nil {
|
||||
SendJSON(w, http.StatusInternalServerError, Response{
|
||||
Error: fmt.Sprintf("Error getting likes: %s", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Map user data to just the essentials for front-end.
|
||||
var result = []Liker{}
|
||||
for _, user := range users {
|
||||
result = append(result, Liker{
|
||||
Username: user.Username,
|
||||
Avatar: user.VisibleAvatarURL(currentUser),
|
||||
Relationship: user.UserRelationship,
|
||||
})
|
||||
}
|
||||
|
||||
// Send success response.
|
||||
SendJSON(w, http.StatusOK, Response{
|
||||
OK: true,
|
||||
Likes: result,
|
||||
Pager: pager,
|
||||
Pages: pager.Pages(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -96,6 +96,12 @@ func View() http.HandlerFunc {
|
|||
}
|
||||
commentLikeMap := models.MapLikes(currentUser, "comments", commentIDs)
|
||||
|
||||
// Get the summary of WHO liked this picture.
|
||||
likeExample, likeRemainder, err := models.WhoLikes("photos", photo.ID)
|
||||
if err != nil {
|
||||
log.Error("WhoLikes(photo %d): %s", photo.ID, err)
|
||||
}
|
||||
|
||||
// Populate the user relationships in these comments.
|
||||
models.SetUserRelationshipsInComments(currentUser, comments)
|
||||
|
||||
|
@ -111,6 +117,12 @@ func View() http.HandlerFunc {
|
|||
"Comments": comments,
|
||||
"CommentLikeMap": commentLikeMap,
|
||||
"IsSubscribed": isSubscribed,
|
||||
|
||||
// Details on who likes the photo.
|
||||
"LikeExample": likeExample,
|
||||
"LikeRemainder": likeRemainder,
|
||||
"LikeTableName": "photos",
|
||||
"LikeTableID": photo.ID,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
|
@ -63,6 +63,68 @@ func CountLikes(tableName string, tableID uint64) int64 {
|
|||
return count
|
||||
}
|
||||
|
||||
// WhoLikes something. Returns the first couple users and a count of the remainder.
|
||||
func WhoLikes(tableName string, tableID uint64) ([]*User, int64, error) {
|
||||
var (
|
||||
userIDs = []uint64{}
|
||||
likes = []*Like{}
|
||||
res = DB.Model(&Like{}).Where(
|
||||
"table_name = ? AND table_id = ?",
|
||||
tableName, tableID,
|
||||
).Order("created_at DESC").Limit(2).Scan(&likes)
|
||||
total = CountLikes(tableName, tableID)
|
||||
remainder = total - int64(len(likes))
|
||||
)
|
||||
if res.Error != nil {
|
||||
return nil, 0, res.Error
|
||||
}
|
||||
|
||||
// Collect the user IDs to look up.
|
||||
for _, row := range likes {
|
||||
userIDs = append(userIDs, row.UserID)
|
||||
}
|
||||
|
||||
// Look up the users and return the remainder.
|
||||
users, err := GetUsers(nil, userIDs)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return users, remainder, nil
|
||||
}
|
||||
|
||||
// PaginateLikes returns a paged view of users who've liked something.
|
||||
func PaginateLikes(currentUser *User, tableName string, tableID uint64, pager *Pagination) ([]*User, error) {
|
||||
var (
|
||||
l = []*Like{}
|
||||
userIDs = []uint64{}
|
||||
)
|
||||
|
||||
query := DB.Where(
|
||||
"table_name = ? AND table_id = ?",
|
||||
tableName, tableID,
|
||||
).Order(
|
||||
pager.Sort,
|
||||
)
|
||||
|
||||
// Get the total count.
|
||||
query.Model(&Like{}).Count(&pager.Total)
|
||||
|
||||
// Get the page of likes.
|
||||
result := query.Offset(
|
||||
pager.GetOffset(),
|
||||
).Limit(pager.PerPage).Find(&l)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
// Map the user IDs in.
|
||||
for _, like := range l {
|
||||
userIDs = append(userIDs, like.UserID)
|
||||
}
|
||||
return GetUsers(currentUser, userIDs)
|
||||
}
|
||||
|
||||
// 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{}
|
||||
|
|
|
@ -452,6 +452,23 @@ func (u *User) NameOrUsername() string {
|
|||
}
|
||||
}
|
||||
|
||||
// VisibleAvatarURL returns a URL to the user's avatar taking into account
|
||||
// their relationship with the current user. For example, if the avatar is
|
||||
// friends-only and the current user can't see it, returns the path to the
|
||||
// yellow placeholder avatar instead.
|
||||
//
|
||||
// Expects that UserRelationships are available on the user.
|
||||
func (u *User) VisibleAvatarURL(currentUser *User) string {
|
||||
if u.ProfilePhoto.Visibility == PhotoPrivate && !u.UserRelationship.IsPrivateGranted {
|
||||
return "/static/img/shy-private.png"
|
||||
} else if u.ProfilePhoto.Visibility == PhotoFriends && !u.UserRelationship.IsFriend {
|
||||
return "/static/img/shy-friends.png"
|
||||
} else if u.ProfilePhoto.CroppedFilename != "" {
|
||||
return config.PhotoWebPath + "/" + u.ProfilePhoto.CroppedFilename
|
||||
}
|
||||
return "/static/img/shy.png"
|
||||
}
|
||||
|
||||
// HashPassword sets the user's hashed (bcrypt) password.
|
||||
func (u *User) HashPassword(password string) error {
|
||||
passwd, err := bcrypt.GenerateFromPassword([]byte(password), config.BcryptCost)
|
||||
|
|
|
@ -97,6 +97,7 @@ func New() http.Handler {
|
|||
mux.HandleFunc("/v1/users/me", api.LoginOK())
|
||||
mux.HandleFunc("/v1/users/check-username", api.UsernameCheck())
|
||||
mux.Handle("/v1/likes", middleware.LoginRequired(api.Likes()))
|
||||
mux.Handle("/v1/likes/users", middleware.LoginRequired(api.WhoLikes()))
|
||||
mux.Handle("/v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))
|
||||
mux.Handle("/v1/notifications/delete", middleware.LoginRequired(api.ClearNotification()))
|
||||
mux.Handle("/v1/comment-photos/remove-orphaned", api.RemoveOrphanedCommentPhotos())
|
||||
|
|
|
@ -119,6 +119,7 @@ func (t *Template) Reload() error {
|
|||
var baseTemplates = []string{
|
||||
config.TemplatePath + "/base.html",
|
||||
config.TemplatePath + "/partials/user_avatar.html",
|
||||
config.TemplatePath + "/partials/like_modal.html",
|
||||
}
|
||||
|
||||
// templates returns a template chain with the base templates preceding yours.
|
||||
|
|
|
@ -85,6 +85,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
// Common event handlers for bulma modals.
|
||||
(document.querySelectorAll(".modal-background, .modal-close, .photo-modal") || []).forEach(node => {
|
||||
const target = node.closest(".modal");
|
||||
if (target.classList.contains("vue-managed")) return;
|
||||
node.addEventListener("click", () => {
|
||||
target.classList.remove("is-active");
|
||||
});
|
||||
|
@ -126,4 +127,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
}
|
||||
});
|
||||
})
|
||||
});
|
||||
});
|
||||
|
|
|
@ -256,7 +256,7 @@
|
|||
</div>
|
||||
{{else}}
|
||||
<div class="block p-4">
|
||||
<div class="tabs is-boxed">
|
||||
<div class="tabs is-boxed mb-0">
|
||||
<ul>
|
||||
<li class="is-active">
|
||||
<a>
|
||||
|
@ -280,7 +280,12 @@
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<!-- Show who likes this user -->
|
||||
{{if not .CurrentUser.IsShy}}
|
||||
{{template "like-example" .}}
|
||||
{{end}}
|
||||
|
||||
<div class="columns mt-1">
|
||||
|
||||
<div class="column is-two-thirds">
|
||||
<div class="card block">
|
||||
|
|
|
@ -334,6 +334,9 @@
|
|||
<script type="text/javascript" src="/static/js/vue-3.2.45.js"></script>
|
||||
{{template "scripts" .}}
|
||||
|
||||
<!-- Likes modal -->
|
||||
{{template "like-modal"}}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
|
|
@ -251,6 +251,15 @@
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow">
|
||||
<!-- Button to inspect the likes -->
|
||||
<a href="#" class="has-text-dark"
|
||||
onclick="ShowLikeModal('comments', {{.ID}}); return false">
|
||||
<span class="icon"><i class="fa fa-eye"></i></span>
|
||||
<span>Likes</span>
|
||||
</a>
|
||||
</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>
|
||||
|
|
209
web/templates/partials/like_modal.html
Normal file
209
web/templates/partials/like_modal.html
Normal file
|
@ -0,0 +1,209 @@
|
|||
<!-- "Likes" modal to see who liked a thing -->
|
||||
|
||||
<!-- Reusable "Liked by Alice, Bob and 5 others" widget that invokes
|
||||
the Like Modal. Requirements: pass in a context providing the
|
||||
following variables:
|
||||
|
||||
- LikeExample: slice of users
|
||||
- LikeRemainder: integer
|
||||
- LikeTableName: like 'photos'
|
||||
- LikeTableID: integer
|
||||
|
||||
Call this like: {{template "like-example" .}}
|
||||
-->
|
||||
{{define "like-example"}}
|
||||
{{$Outer := .}}
|
||||
{{if .LikeExample}}
|
||||
<div class="mt-4 mb-2 has-text-centered">
|
||||
Liked by
|
||||
|
||||
<!-- User list -->
|
||||
{{range $i, $User := .LikeExample}}
|
||||
<!-- Avatar -->
|
||||
<figure class="image is-16x16 is-inline-block">
|
||||
<a href="/u/{{$User.Username}}" class="has-text-dark">
|
||||
{{if $User.ProfilePhoto.ID}}
|
||||
{{if and (eq $User.ProfilePhoto.Visibility "private") (not $User.UserRelationship.IsPrivateGranted)}}
|
||||
<img class="is-rounded" src="/static/img/shy-private.png">
|
||||
{{else if and (eq $User.ProfilePhoto.Visibility "friends") (not $User.UserRelationship.IsFriend)}}
|
||||
<img class="is-rounded" src="/static/img/shy-friends.png">
|
||||
{{else}}
|
||||
<img class="is-rounded" src="{{PhotoURL $User.ProfilePhoto.CroppedFilename}}">
|
||||
{{end}}
|
||||
{{else}}
|
||||
<img class="is-rounded" src="/static/img/shy.png">
|
||||
{{end}}
|
||||
</a>
|
||||
</figure>
|
||||
|
||||
<!-- Username plus a comma if multiple -->
|
||||
<a href="/u/{{$User.Username}}">
|
||||
{{- $User.Username -}}
|
||||
</a>{{if and (eq (len $Outer.LikeExample) 2) (not $Outer.LikeRemainder)}}
|
||||
{{if eq $i 0}}
|
||||
and
|
||||
{{end}}
|
||||
{{- else if gt (len $Outer.LikeExample) 1}},{{end}}
|
||||
{{end}}
|
||||
|
||||
<!-- Others -->
|
||||
{{if .LikeRemainder}}
|
||||
and
|
||||
<a
|
||||
href="#"
|
||||
onclick="ShowLikeModal('{{.LikeTableName}}', {{.LikeTableID}}); return false"
|
||||
>
|
||||
{{.LikeRemainder}} other{{Pluralize64 .LikeRemainder}}
|
||||
</a>.
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "like-modal"}}
|
||||
<div id="like-modal-app">
|
||||
<div class="modal vue-managed" :class="{'is-active': visible}">
|
||||
<div class="modal-background" @click="visible=false"></div>
|
||||
<div class="modal-content">
|
||||
<div class="card">
|
||||
<div class="card-header has-background-info">
|
||||
<p class="card-header-title has-text-light">
|
||||
<i class="fa fa-heart mr-2"></i> [[title]]
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
|
||||
<div v-if="busy">
|
||||
<i class="fa fa-spinner fa-spin"></i> Loading likes...
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="block">
|
||||
Found [[ total ]] like[[ total === 1 ? '' : 's' ]]
|
||||
(page [[ page ]] of [[ pages ]]).
|
||||
</p>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-one-third"
|
||||
v-for="row in result">
|
||||
|
||||
<!-- Avatar -->
|
||||
<figure class="image is-16x16 is-inline-block mr-2">
|
||||
<a :href="'/u/'+row.username" class="has-text-dark">
|
||||
<img class="is-rounded" :src="row.avatar">
|
||||
</a>
|
||||
</figure>
|
||||
|
||||
<!-- Username link -->
|
||||
<a :href="'/u/'+row.username" target="_blank">[[ row.username ]]</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pager buttons -->
|
||||
<div class="columns is-mobile">
|
||||
<div class="column is-narrow">
|
||||
<button type="button" class="button"
|
||||
@click="prevPage()"
|
||||
:disabled="page <= 1">
|
||||
Previous
|
||||
</button>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<button type="button" class="button"
|
||||
@click="nextPage()"
|
||||
:disabled="page >= pages">
|
||||
Next page
|
||||
</button>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<button type="button" class="button is-primary"
|
||||
@click="visible=false">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
// Implemented in Vue widget.
|
||||
var ShowLikeModal = function(tableName, tableID) {};
|
||||
|
||||
const likeModalApp = Vue.createApp({
|
||||
delimiters: ['[[', ']]'],
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
title: "Likes",
|
||||
|
||||
busy: false,
|
||||
tableName: "photos",
|
||||
tableID: 0,
|
||||
page: 1,
|
||||
pages: 0,
|
||||
total: 0,
|
||||
result: [],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
ShowLikeModal = this.ShowLikeModal;
|
||||
},
|
||||
methods: {
|
||||
ShowLikeModal(tableName, tableID) {
|
||||
this.tableName = tableName;
|
||||
this.tableID = tableID;
|
||||
this.visible = true;
|
||||
this.page = 1;
|
||||
this.get();
|
||||
},
|
||||
|
||||
prevPage() {
|
||||
this.page--;
|
||||
if (this.page <= 1) {
|
||||
this.page = 1;
|
||||
}
|
||||
this.get();
|
||||
},
|
||||
nextPage() {
|
||||
this.page++;
|
||||
this.get();
|
||||
},
|
||||
|
||||
get() {
|
||||
this.busy = true;
|
||||
return fetch("/v1/likes/users?" + new URLSearchParams({
|
||||
table_name: this.tableName,
|
||||
table_id: this.tableID,
|
||||
page: this.page,
|
||||
}), {
|
||||
method: "GET",
|
||||
mode: "same-origin",
|
||||
cache: "no-cache",
|
||||
credentials: "same-origin",
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.StatusCode !== 200) {
|
||||
window.alert(data.data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
let likes = data.data.likes;
|
||||
this.pages = data.data.pages;
|
||||
this.total = data.data.pager.Total;
|
||||
this.result = likes;
|
||||
}).catch(resp => {
|
||||
window.alert(resp);
|
||||
}).finally(() => {
|
||||
this.busy = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
likeModalApp.mount("#like-modal-app");
|
||||
</script>
|
||||
{{end}}
|
|
@ -84,6 +84,9 @@
|
|||
{{.Photo.Caption}}
|
||||
{{else}}<em>No caption</em>{{end}}
|
||||
|
||||
<!-- Who likes this photo? -->
|
||||
{{template "like-example" .}}
|
||||
|
||||
<!-- Like & Comments buttons -->
|
||||
<div class="mt-4 mb-2 columns is-centered is-mobile is-gapless">
|
||||
<div class="column is-narrow mr-2">
|
||||
|
@ -249,6 +252,15 @@
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow">
|
||||
<!-- Button to inspect the likes -->
|
||||
<a href="#" class="has-text-dark"
|
||||
onclick="ShowLikeModal('comments', {{.ID}}); return false">
|
||||
<span class="icon"><i class="fa fa-eye"></i></span>
|
||||
<span>Likes</span>
|
||||
</a>
|
||||
</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>
|
||||
|
|
Loading…
Reference in New Issue
Block a user