forked from mirrors/pronouns.cc
Merge pull request 'add custom name/label/pronoun preferences (closes #42)' (#51) from feature/custom-preferences into main
Reviewed-on: https://codeberg.org/u1f320/pronouns.cc/pulls/51
This commit is contained in:
commit
2a15c519f3
34 changed files with 2572 additions and 321 deletions
|
@ -40,13 +40,13 @@ func (w *WordStatus) UnmarshalJSON(src []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w WordStatus) Valid(extra ...WordStatus) bool {
|
func (w WordStatus) Valid(extra CustomPreferences) bool {
|
||||||
if w == StatusFavourite || w == StatusOkay || w == StatusJokingly || w == StatusFriendsOnly || w == StatusAvoid {
|
if w == StatusFavourite || w == StatusOkay || w == StatusJokingly || w == StatusFriendsOnly || w == StatusAvoid {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range extra {
|
for k := range extra {
|
||||||
if w == extra[i] {
|
if string(w) == k {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -58,7 +58,7 @@ type FieldEntry struct {
|
||||||
Status WordStatus `json:"status"`
|
Status WordStatus `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fe FieldEntry) Validate() string {
|
func (fe FieldEntry) Validate(custom CustomPreferences) string {
|
||||||
if fe.Value == "" {
|
if fe.Value == "" {
|
||||||
return "value cannot be empty"
|
return "value cannot be empty"
|
||||||
}
|
}
|
||||||
|
@ -67,7 +67,7 @@ func (fe FieldEntry) Validate() string {
|
||||||
return fmt.Sprintf("name must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(fe.Value)))
|
return fmt.Sprintf("name must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(fe.Value)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !fe.Status.Valid() {
|
if !fe.Status.Valid(custom) {
|
||||||
return "status is invalid"
|
return "status is invalid"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,7 +80,7 @@ type PronounEntry struct {
|
||||||
Status WordStatus `json:"status"`
|
Status WordStatus `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p PronounEntry) Validate() string {
|
func (p PronounEntry) Validate(custom CustomPreferences) string {
|
||||||
if p.Pronouns == "" {
|
if p.Pronouns == "" {
|
||||||
return "pronouns cannot be empty"
|
return "pronouns cannot be empty"
|
||||||
}
|
}
|
||||||
|
@ -95,7 +95,7 @@ func (p PronounEntry) Validate() string {
|
||||||
return fmt.Sprintf("pronouns must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(p.Pronouns)))
|
return fmt.Sprintf("pronouns must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(p.Pronouns)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !p.Status.Valid() {
|
if !p.Status.Valid(custom) {
|
||||||
return "status is invalid"
|
return "status is invalid"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@ type Field struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate validates this field. If it is invalid, a non-empty string is returned as error message.
|
// Validate validates this field. If it is invalid, a non-empty string is returned as error message.
|
||||||
func (f Field) Validate() string {
|
func (f Field) Validate(custom CustomPreferences) string {
|
||||||
if f.Name == "" {
|
if f.Name == "" {
|
||||||
return "name cannot be empty"
|
return "name cannot be empty"
|
||||||
}
|
}
|
||||||
|
@ -42,7 +42,7 @@ func (f Field) Validate() string {
|
||||||
return fmt.Sprintf("entries.%d: max length is %d characters, length is %d", i, FieldEntryMaxLength, length)
|
return fmt.Sprintf("entries.%d: max length is %d characters, length is %d", i, FieldEntryMaxLength, length)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !entry.Status.Valid() {
|
if !entry.Status.Valid(custom) {
|
||||||
return fmt.Sprintf("entries.%d: status is invalid", i)
|
return fmt.Sprintf("entries.%d: status is invalid", i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,9 +4,12 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/u1f320/pronouns.cc/backend/common"
|
||||||
|
"codeberg.org/u1f320/pronouns.cc/backend/icons"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"github.com/georgysavva/scany/v2/pgxscan"
|
"github.com/georgysavva/scany/v2/pgxscan"
|
||||||
|
@ -49,8 +52,47 @@ type User struct {
|
||||||
DeletedAt *time.Time
|
DeletedAt *time.Time
|
||||||
SelfDelete *bool
|
SelfDelete *bool
|
||||||
DeleteReason *string
|
DeleteReason *string
|
||||||
|
|
||||||
|
CustomPreferences CustomPreferences
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CustomPreferences = map[string]CustomPreference
|
||||||
|
|
||||||
|
type CustomPreference struct {
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Tooltip string `json:"tooltip"`
|
||||||
|
Size PreferenceSize `json:"size"`
|
||||||
|
Muted bool `json:"muted"`
|
||||||
|
Favourite bool `json:"favourite"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CustomPreference) Validate() string {
|
||||||
|
if !icons.IsValid(c.Icon) {
|
||||||
|
return fmt.Sprintf("custom preference icon %q is invalid", c.Icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Tooltip == "" {
|
||||||
|
return "custom preference tooltip is empty"
|
||||||
|
}
|
||||||
|
if common.StringLength(&c.Tooltip) > FieldEntryMaxLength {
|
||||||
|
return fmt.Sprintf("custom preference tooltip is too long, max %d characters, is %d characters", FieldEntryMaxLength, common.StringLength(&c.Tooltip))
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Size != PreferenceSizeLarge && c.Size != PreferenceSizeNormal && c.Size != PreferenceSizeSmall {
|
||||||
|
return fmt.Sprintf("custom preference size %q is invalid", string(c.Size))
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreferenceSize string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PreferenceSizeLarge PreferenceSize = "large"
|
||||||
|
PreferenceSizeNormal PreferenceSize = "normal"
|
||||||
|
PreferenceSizeSmall PreferenceSize = "small"
|
||||||
|
)
|
||||||
|
|
||||||
func (u User) NumProviders() (numProviders int) {
|
func (u User) NumProviders() (numProviders int) {
|
||||||
if u.Discord != nil {
|
if u.Discord != nil {
|
||||||
numProviders++
|
numProviders++
|
||||||
|
@ -456,8 +498,9 @@ func (db *DB) UpdateUser(
|
||||||
memberTitle *string, listPrivate *bool,
|
memberTitle *string, listPrivate *bool,
|
||||||
links *[]string,
|
links *[]string,
|
||||||
avatar *string,
|
avatar *string,
|
||||||
|
customPreferences *CustomPreferences,
|
||||||
) (u User, err error) {
|
) (u User, err error) {
|
||||||
if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil {
|
if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil && customPreferences == nil {
|
||||||
sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql()
|
sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return u, errors.Wrap(err, "building sql")
|
return u, errors.Wrap(err, "building sql")
|
||||||
|
@ -499,6 +542,9 @@ func (db *DB) UpdateUser(
|
||||||
if listPrivate != nil {
|
if listPrivate != nil {
|
||||||
builder = builder.Set("list_private", *listPrivate)
|
builder = builder.Set("list_private", *listPrivate)
|
||||||
}
|
}
|
||||||
|
if customPreferences != nil {
|
||||||
|
builder = builder.Set("custom_preferences", *customPreferences)
|
||||||
|
}
|
||||||
|
|
||||||
if avatar != nil {
|
if avatar != nil {
|
||||||
if *avatar == "" {
|
if *avatar == "" {
|
||||||
|
|
1968
backend/icons/icons.go
Normal file
1968
backend/icons/icons.go
Normal file
File diff suppressed because it is too large
Load diff
|
@ -103,15 +103,15 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("name", &cmr.Names); err != nil {
|
if err := validateSlicePtr("name", &cmr.Names, u.CustomPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("pronoun", &cmr.Pronouns); err != nil {
|
if err := validateSlicePtr("pronoun", &cmr.Pronouns, u.CustomPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("field", &cmr.Fields); err != nil {
|
if err := validateSlicePtr("field", &cmr.Fields, u.CustomPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -186,12 +186,12 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
|
||||||
}
|
}
|
||||||
|
|
||||||
type validator interface {
|
type validator interface {
|
||||||
Validate() string
|
Validate(custom db.CustomPreferences) string
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateSlicePtr validates a slice of validators.
|
// validateSlicePtr validates a slice of validators.
|
||||||
// If the slice is nil, a nil error is returned (assuming that the field is not required)
|
// If the slice is nil, a nil error is returned (assuming that the field is not required)
|
||||||
func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError {
|
func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError {
|
||||||
if slice == nil {
|
if slice == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -211,7 +211,7 @@ func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError {
|
||||||
|
|
||||||
// validate all fields
|
// validate all fields
|
||||||
for i, pronouns := range *slice {
|
for i, pronouns := range *slice {
|
||||||
if s := pronouns.Validate(); s != "" {
|
if s := pronouns.Validate(custom); s != "" {
|
||||||
return &server.APIError{
|
return &server.APIError{
|
||||||
Code: server.ErrBadRequest,
|
Code: server.ErrBadRequest,
|
||||||
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),
|
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),
|
||||||
|
|
|
@ -42,10 +42,11 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember boo
|
||||||
Fields: db.NotNull(fields),
|
Fields: db.NotNull(fields),
|
||||||
|
|
||||||
User: PartialUser{
|
User: PartialUser{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
DisplayName: u.DisplayName,
|
DisplayName: u.DisplayName,
|
||||||
Avatar: u.Avatar,
|
Avatar: u.Avatar,
|
||||||
|
CustomPreferences: u.CustomPreferences,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,10 +58,11 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember boo
|
||||||
}
|
}
|
||||||
|
|
||||||
type PartialUser struct {
|
type PartialUser struct {
|
||||||
ID xid.ID `json:"id"`
|
ID xid.ID `json:"id"`
|
||||||
Username string `json:"name"`
|
Username string `json:"name"`
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
|
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
|
@ -41,6 +41,11 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.APIError{Code: server.ErrMemberNotFound}
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
m, err := s.DB.Member(ctx, id)
|
m, err := s.DB.Member(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == db.ErrMemberNotFound {
|
if err == db.ErrMemberNotFound {
|
||||||
|
@ -148,15 +153,15 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("name", req.Names); err != nil {
|
if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("pronoun", req.Pronouns); err != nil {
|
if err := validateSlicePtr("pronoun", req.Pronouns, u.CustomPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("field", req.Fields); err != nil {
|
if err := validateSlicePtr("field", req.Fields, u.CustomPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -271,11 +276,6 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := s.DB.User(ctx, claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "getting user")
|
|
||||||
}
|
|
||||||
|
|
||||||
// echo the updated member back on success
|
// echo the updated member back on success
|
||||||
render.JSON(w, r, dbMemberToMember(u, m, fields, true))
|
render.JSON(w, r, dbMemberToMember(u, m, fields, true))
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -12,17 +12,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type GetUserResponse struct {
|
type GetUserResponse struct {
|
||||||
ID xid.ID `json:"id"`
|
ID xid.ID `json:"id"`
|
||||||
Username string `json:"name"`
|
Username string `json:"name"`
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Bio *string `json:"bio"`
|
Bio *string `json:"bio"`
|
||||||
MemberTitle *string `json:"member_title"`
|
MemberTitle *string `json:"member_title"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
Links []string `json:"links"`
|
Links []string `json:"links"`
|
||||||
Names []db.FieldEntry `json:"names"`
|
Names []db.FieldEntry `json:"names"`
|
||||||
Pronouns []db.PronounEntry `json:"pronouns"`
|
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||||
Members []PartialMember `json:"members"`
|
Members []PartialMember `json:"members"`
|
||||||
Fields []db.Field `json:"fields"`
|
Fields []db.Field `json:"fields"`
|
||||||
|
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetMeResponse struct {
|
type GetMeResponse struct {
|
||||||
|
@ -59,16 +60,17 @@ type PartialMember struct {
|
||||||
|
|
||||||
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUserResponse {
|
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUserResponse {
|
||||||
resp := GetUserResponse{
|
resp := GetUserResponse{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
DisplayName: u.DisplayName,
|
DisplayName: u.DisplayName,
|
||||||
Bio: u.Bio,
|
Bio: u.Bio,
|
||||||
MemberTitle: u.MemberTitle,
|
MemberTitle: u.MemberTitle,
|
||||||
Avatar: u.Avatar,
|
Avatar: u.Avatar,
|
||||||
Links: db.NotNull(u.Links),
|
Links: db.NotNull(u.Links),
|
||||||
Names: db.NotNull(u.Names),
|
Names: db.NotNull(u.Names),
|
||||||
Pronouns: db.NotNull(u.Pronouns),
|
Pronouns: db.NotNull(u.Pronouns),
|
||||||
Fields: db.NotNull(fields),
|
Fields: db.NotNull(fields),
|
||||||
|
CustomPreferences: u.CustomPreferences,
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.Members = make([]PartialMember, len(members))
|
resp.Members = make([]PartialMember, len(members))
|
||||||
|
|
|
@ -10,19 +10,21 @@ import (
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PatchUserRequest struct {
|
type PatchUserRequest struct {
|
||||||
Username *string `json:"username"`
|
Username *string `json:"username"`
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Bio *string `json:"bio"`
|
Bio *string `json:"bio"`
|
||||||
MemberTitle *string `json:"member_title"`
|
MemberTitle *string `json:"member_title"`
|
||||||
Links *[]string `json:"links"`
|
Links *[]string `json:"links"`
|
||||||
Names *[]db.FieldEntry `json:"names"`
|
Names *[]db.FieldEntry `json:"names"`
|
||||||
Pronouns *[]db.PronounEntry `json:"pronouns"`
|
Pronouns *[]db.PronounEntry `json:"pronouns"`
|
||||||
Fields *[]db.Field `json:"fields"`
|
Fields *[]db.Field `json:"fields"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
ListPrivate *bool `json:"list_private"`
|
ListPrivate *bool `json:"list_private"`
|
||||||
|
CustomPreferences *db.CustomPreferences `json:"custom_preferences"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// patchUser parses a PatchUserRequest and updates the user with the given ID.
|
// patchUser parses a PatchUserRequest and updates the user with the given ID.
|
||||||
|
@ -57,7 +59,8 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
req.Fields == nil &&
|
req.Fields == nil &&
|
||||||
req.Names == nil &&
|
req.Names == nil &&
|
||||||
req.Pronouns == nil &&
|
req.Pronouns == nil &&
|
||||||
req.Avatar == nil {
|
req.Avatar == nil &&
|
||||||
|
req.CustomPreferences == nil {
|
||||||
return server.APIError{
|
return server.APIError{
|
||||||
Code: server.ErrBadRequest,
|
Code: server.ErrBadRequest,
|
||||||
Details: "Data must not be empty",
|
Details: "Data must not be empty",
|
||||||
|
@ -103,18 +106,35 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("name", req.Names); err != nil {
|
if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("pronoun", req.Pronouns); err != nil {
|
if err := validateSlicePtr("pronoun", req.Pronouns, u.CustomPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("field", req.Fields); err != nil {
|
if err := validateSlicePtr("field", req.Fields, u.CustomPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validate custom preferences
|
||||||
|
if req.CustomPreferences != nil {
|
||||||
|
if count := len(*req.CustomPreferences); count > db.MaxFields {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: fmt.Sprintf("Too many custom preferences (max %d, current %d)", db.MaxFields, count)}
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range *req.CustomPreferences {
|
||||||
|
_, err := uuid.Parse(k)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "One or more custom preference IDs is not a UUID."}
|
||||||
|
}
|
||||||
|
if s := v.Validate(); s != "" {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: s}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// update avatar
|
// update avatar
|
||||||
var avatarHash *string = nil
|
var avatarHash *string = nil
|
||||||
if req.Avatar != nil {
|
if req.Avatar != nil {
|
||||||
|
@ -186,7 +206,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash)
|
u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.CustomPreferences)
|
||||||
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
|
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
|
||||||
log.Errorf("updating user: %v", err)
|
log.Errorf("updating user: %v", err)
|
||||||
return err
|
return err
|
||||||
|
@ -263,12 +283,12 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
type validator interface {
|
type validator interface {
|
||||||
Validate() string
|
Validate(custom db.CustomPreferences) string
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateSlicePtr validates a slice of validators.
|
// validateSlicePtr validates a slice of validators.
|
||||||
// If the slice is nil, a nil error is returned (assuming that the field is not required)
|
// If the slice is nil, a nil error is returned (assuming that the field is not required)
|
||||||
func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError {
|
func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError {
|
||||||
if slice == nil {
|
if slice == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -288,7 +308,7 @@ func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError {
|
||||||
|
|
||||||
// validate all fields
|
// validate all fields
|
||||||
for i, pronouns := range *slice {
|
for i, pronouns := range *slice {
|
||||||
if s := pronouns.Validate(); s != "" {
|
if s := pronouns.Validate(custom); s != "" {
|
||||||
return &server.APIError{
|
return &server.APIError{
|
||||||
Code: server.ErrBadRequest,
|
Code: server.ErrBadRequest,
|
||||||
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),
|
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),
|
||||||
|
|
44
frontend/icons.js
Normal file
44
frontend/icons.js
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
// This script regenerates the list of icons for the frontend (frontend/src/icons.json)
|
||||||
|
// and the backend (backend/icons/icons.go) from the currently installed version of Bootstrap Icons.
|
||||||
|
// Run with `pnpm node icons.js` in the frontend directory.
|
||||||
|
|
||||||
|
import { writeFileSync } from "fs";
|
||||||
|
import icons from "bootstrap-icons/font/bootstrap-icons.json" assert { type: "json" };
|
||||||
|
|
||||||
|
const keys = Object.keys(icons);
|
||||||
|
|
||||||
|
console.log(`Found ${keys.length} icons`);
|
||||||
|
const output = JSON.stringify(keys);
|
||||||
|
console.log(`Saving file as src/icons.ts`);
|
||||||
|
|
||||||
|
writeFileSync("src/icons.ts", `const icons = ${output};\nexport default icons;`);
|
||||||
|
|
||||||
|
const goCode1 = `// Generated code. DO NOT EDIT
|
||||||
|
package icons
|
||||||
|
|
||||||
|
var icons = [...]string{
|
||||||
|
`;
|
||||||
|
|
||||||
|
const goCode2 = `}
|
||||||
|
|
||||||
|
// IsValid returns true if the input is the name of a Bootstrap icon.
|
||||||
|
func IsValid(name string) bool {
|
||||||
|
for i := range icons {
|
||||||
|
if icons[i] == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let goOutput = goCode1;
|
||||||
|
|
||||||
|
keys.forEach((element) => {
|
||||||
|
goOutput += ` "${element}",\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
goOutput += goCode2;
|
||||||
|
|
||||||
|
console.log("Writing Go code");
|
||||||
|
writeFileSync("../backend/icons/icons.go", goOutput);
|
2
frontend/src/icons.ts
Normal file
2
frontend/src/icons.ts
Normal file
File diff suppressed because one or more lines are too long
48
frontend/src/lib/api/default_preferences.ts
Normal file
48
frontend/src/lib/api/default_preferences.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { type CustomPreferences, PreferenceSize } from "./entities";
|
||||||
|
|
||||||
|
const defaultPreferences: CustomPreferences = {
|
||||||
|
favourite: {
|
||||||
|
icon: "heart-fill",
|
||||||
|
tooltip: "Favourite",
|
||||||
|
size: PreferenceSize.Large,
|
||||||
|
muted: false,
|
||||||
|
favourite: true,
|
||||||
|
},
|
||||||
|
okay: {
|
||||||
|
icon: "hand-thumbs-up",
|
||||||
|
tooltip: "Okay",
|
||||||
|
size: PreferenceSize.Normal,
|
||||||
|
muted: false,
|
||||||
|
favourite: false,
|
||||||
|
},
|
||||||
|
jokingly: {
|
||||||
|
icon: "emoji-laughing",
|
||||||
|
tooltip: "Jokingly",
|
||||||
|
size: PreferenceSize.Normal,
|
||||||
|
muted: false,
|
||||||
|
favourite: false,
|
||||||
|
},
|
||||||
|
friends_only: {
|
||||||
|
icon: "people",
|
||||||
|
tooltip: "Friends only",
|
||||||
|
size: PreferenceSize.Normal,
|
||||||
|
muted: false,
|
||||||
|
favourite: false,
|
||||||
|
},
|
||||||
|
avoid: {
|
||||||
|
icon: "hand-thumbs-down",
|
||||||
|
tooltip: "Avoid",
|
||||||
|
size: PreferenceSize.Small,
|
||||||
|
muted: true,
|
||||||
|
favourite: false,
|
||||||
|
},
|
||||||
|
missing: {
|
||||||
|
icon: "question-lg",
|
||||||
|
tooltip: "Unknown (missing)",
|
||||||
|
size: PreferenceSize.Normal,
|
||||||
|
muted: false,
|
||||||
|
favourite: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defaultPreferences;
|
|
@ -16,6 +16,25 @@ export interface User {
|
||||||
pronouns: Pronoun[];
|
pronouns: Pronoun[];
|
||||||
members: PartialMember[];
|
members: PartialMember[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
|
custom_preferences: CustomPreferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomPreferences {
|
||||||
|
[key: string]: CustomPreference;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomPreference {
|
||||||
|
icon: string;
|
||||||
|
tooltip: string;
|
||||||
|
size: PreferenceSize;
|
||||||
|
muted: boolean;
|
||||||
|
favourite: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PreferenceSize {
|
||||||
|
Large = "large",
|
||||||
|
Normal = "normal",
|
||||||
|
Small = "small",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeUser extends User {
|
export interface MeUser extends User {
|
||||||
|
@ -39,13 +58,13 @@ export interface Field {
|
||||||
|
|
||||||
export interface FieldEntry {
|
export interface FieldEntry {
|
||||||
value: string;
|
value: string;
|
||||||
status: WordStatus;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pronoun {
|
export interface Pronoun {
|
||||||
pronouns: string;
|
pronouns: string;
|
||||||
display_text: string | null;
|
display_text: string | null;
|
||||||
status: WordStatus;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum WordStatus {
|
export enum WordStatus {
|
||||||
|
@ -80,6 +99,7 @@ export interface MemberPartialUser {
|
||||||
name: string;
|
name: string;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
|
custom_preferences: CustomPreferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Invite {
|
export interface Invite {
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Field } from "$lib/api/entities";
|
import type { CustomPreferences, Field } from "$lib/api/entities";
|
||||||
|
|
||||||
import StatusLine from "./StatusLine.svelte";
|
import StatusLine from "./StatusLine.svelte";
|
||||||
|
|
||||||
export let field: Field;
|
export let field: Field;
|
||||||
|
export let preferences: CustomPreferences;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3>{field.name}</h3>
|
<h3>{field.name}</h3>
|
||||||
<ul class="list-unstyled fs-5">
|
<ul class="list-unstyled fs-5">
|
||||||
{#each field.entries as entry}
|
{#each field.entries as entry}
|
||||||
<li><StatusLine status={entry.status}>{entry.value}</StatusLine></li>
|
<li><StatusLine {preferences} status={entry.status}>{entry.value}</StatusLine></li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,8 +4,8 @@
|
||||||
export let icon: string;
|
export let icon: string;
|
||||||
export let color: "primary" | "secondary" | "success" | "danger";
|
export let color: "primary" | "secondary" | "success" | "danger";
|
||||||
export let tooltip: string;
|
export let tooltip: string;
|
||||||
export let active: boolean = false;
|
export let active = false;
|
||||||
export let disabled: boolean = false;
|
export let disabled = false;
|
||||||
export let type: string | undefined = undefined;
|
export let type: string | undefined = undefined;
|
||||||
export let id: string | undefined = undefined;
|
export let id: string | undefined = undefined;
|
||||||
|
|
||||||
|
|
|
@ -1,53 +1,24 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Icon, Tooltip } from "sveltestrap";
|
import { Icon, Tooltip } from "sveltestrap";
|
||||||
|
|
||||||
import { WordStatus } from "$lib/api/entities";
|
import type { CustomPreference, CustomPreferences } from "$lib/api/entities";
|
||||||
|
import defaultPreferences from "$lib/api/default_preferences";
|
||||||
|
|
||||||
export let status: WordStatus;
|
export let preferences: CustomPreferences;
|
||||||
|
export let status: string;
|
||||||
export let className: string | null = null;
|
export let className: string | null = null;
|
||||||
|
|
||||||
const iconFor = (wordStatus: WordStatus) => {
|
let mergedPreferences: CustomPreferences;
|
||||||
switch (wordStatus) {
|
$: mergedPreferences = Object.assign(defaultPreferences, preferences);
|
||||||
case WordStatus.Favourite:
|
|
||||||
return "heart-fill";
|
|
||||||
case WordStatus.Okay:
|
|
||||||
return "hand-thumbs-up";
|
|
||||||
case WordStatus.Jokingly:
|
|
||||||
return "emoji-laughing";
|
|
||||||
case WordStatus.FriendsOnly:
|
|
||||||
return "people";
|
|
||||||
case WordStatus.Avoid:
|
|
||||||
return "hand-thumbs-down";
|
|
||||||
default:
|
|
||||||
return "hand-thumbs-up";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const textFor = (wordStatus: WordStatus) => {
|
let currentPreference: CustomPreference;
|
||||||
switch (wordStatus) {
|
$: currentPreference =
|
||||||
case WordStatus.Favourite:
|
status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing;
|
||||||
return "Favourite";
|
|
||||||
case WordStatus.Okay:
|
|
||||||
return "Okay";
|
|
||||||
case WordStatus.Jokingly:
|
|
||||||
return "Jokingly";
|
|
||||||
case WordStatus.FriendsOnly:
|
|
||||||
return "Friends only";
|
|
||||||
case WordStatus.Avoid:
|
|
||||||
return "Avoid";
|
|
||||||
default:
|
|
||||||
return "Okay";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let statusIcon: string;
|
|
||||||
$: statusIcon = iconFor(status);
|
|
||||||
|
|
||||||
let statusText: string;
|
|
||||||
$: statusText = textFor(status);
|
|
||||||
|
|
||||||
let iconElement: HTMLElement;
|
let iconElement: HTMLElement;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span bind:this={iconElement} tabindex={0}><Icon name={statusIcon} class={className} /></span>
|
<span bind:this={iconElement} tabindex={0}
|
||||||
<Tooltip target={iconElement} placement="top">{statusText}</Tooltip>
|
><Icon name={currentPreference.icon} class={className} /></span
|
||||||
|
>
|
||||||
|
<Tooltip target={iconElement} placement="top">{currentPreference.tooltip}</Tooltip>
|
||||||
|
|
|
@ -1,14 +1,44 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { WordStatus } from "$lib/api/entities";
|
import { PreferenceSize } from "$lib/api/entities";
|
||||||
import StatusIcon from "$lib/components/StatusIcon.svelte";
|
import StatusIcon from "$lib/components/StatusIcon.svelte";
|
||||||
|
|
||||||
export let status: WordStatus;
|
import type { CustomPreference, CustomPreferences } from "$lib/api/entities";
|
||||||
|
import defaultPreferences from "$lib/api/default_preferences";
|
||||||
|
|
||||||
|
export let preferences: CustomPreferences;
|
||||||
|
export let status: string;
|
||||||
|
|
||||||
|
let mergedPreferences: CustomPreferences;
|
||||||
|
$: mergedPreferences = Object.assign(defaultPreferences, preferences);
|
||||||
|
|
||||||
|
let currentPreference: CustomPreference;
|
||||||
|
$: currentPreference =
|
||||||
|
status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing;
|
||||||
|
|
||||||
|
let classes: string;
|
||||||
|
$: classes = setClasses(currentPreference);
|
||||||
|
|
||||||
|
const setClasses = (pref: CustomPreference) => {
|
||||||
|
let classes = "";
|
||||||
|
if (pref.muted) {
|
||||||
|
classes += "text-muted ";
|
||||||
|
}
|
||||||
|
switch (pref.size) {
|
||||||
|
case PreferenceSize.Large:
|
||||||
|
classes += "fs-5";
|
||||||
|
break;
|
||||||
|
case PreferenceSize.Normal:
|
||||||
|
break;
|
||||||
|
case PreferenceSize.Small:
|
||||||
|
classes += "fs-6";
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes.trim();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if status === WordStatus.Favourite}
|
{#if currentPreference.size === PreferenceSize.Large}
|
||||||
<strong class="fs-5"><StatusIcon {status} /> <slot /></strong>
|
<strong class={classes}><StatusIcon {preferences} {status} /> <slot /></strong>
|
||||||
{:else if status === WordStatus.Avoid}
|
|
||||||
<span class="fs-6 text-muted"><StatusIcon {status} /> <slot /></span>
|
|
||||||
{:else}
|
{:else}
|
||||||
<StatusIcon {status} /> <slot />
|
<span class={classes}><StatusIcon {preferences} {status} /> <slot /></span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import type { MeUser } from "./api/entities";
|
||||||
const initialUserValue = null;
|
const initialUserValue = null;
|
||||||
export const userStore = writable<MeUser | null>(initialUserValue);
|
export const userStore = writable<MeUser | null>(initialUserValue);
|
||||||
|
|
||||||
let defaultThemeValue = "dark";
|
const defaultThemeValue = "dark";
|
||||||
const initialThemeValue = browser
|
const initialThemeValue = browser
|
||||||
? window.localStorage.getItem("pronouns-theme") ?? defaultThemeValue
|
? window.localStorage.getItem("pronouns-theme") ?? defaultThemeValue
|
||||||
: defaultThemeValue;
|
: defaultThemeValue;
|
||||||
|
|
|
@ -20,10 +20,12 @@
|
||||||
MAX_MEMBERS,
|
MAX_MEMBERS,
|
||||||
pronounDisplay,
|
pronounDisplay,
|
||||||
userAvatars,
|
userAvatars,
|
||||||
WordStatus,
|
|
||||||
type APIError,
|
type APIError,
|
||||||
type Member,
|
type Member,
|
||||||
type PartialMember,
|
type PartialMember,
|
||||||
|
type CustomPreferences,
|
||||||
|
type FieldEntry,
|
||||||
|
type Pronoun,
|
||||||
} from "$lib/api/entities";
|
} from "$lib/api/entities";
|
||||||
import { PUBLIC_BASE_URL } from "$env/static/public";
|
import { PUBLIC_BASE_URL } from "$env/static/public";
|
||||||
import { apiFetchClient } from "$lib/api/fetch";
|
import { apiFetchClient } from "$lib/api/fetch";
|
||||||
|
@ -34,13 +36,14 @@
|
||||||
import ProfileLink from "./ProfileLink.svelte";
|
import ProfileLink from "./ProfileLink.svelte";
|
||||||
import { memberNameRegex } from "$lib/api/regex";
|
import { memberNameRegex } from "$lib/api/regex";
|
||||||
import StatusLine from "$lib/components/StatusLine.svelte";
|
import StatusLine from "$lib/components/StatusLine.svelte";
|
||||||
|
import defaultPreferences from "$lib/api/default_preferences";
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
let bio: string | null;
|
let bio: string | null;
|
||||||
$: bio = renderMarkdown(data.bio);
|
$: bio = renderMarkdown(data.bio);
|
||||||
|
|
||||||
let memberPage: number = 0;
|
let memberPage = 0;
|
||||||
let memberSlice: PartialMember[] = [];
|
let memberSlice: PartialMember[] = [];
|
||||||
$: memberSlice = data.members.slice(memberPage * 20, (memberPage + 1) * 20);
|
$: memberSlice = data.members.slice(memberPage * 20, (memberPage + 1) * 20);
|
||||||
const totalPages = Math.ceil(data.members.length / 20);
|
const totalPages = Math.ceil(data.members.length / 20);
|
||||||
|
@ -84,8 +87,17 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const favNames = data.names.filter((entry) => entry.status === WordStatus.Favourite);
|
let mergedPreferences: CustomPreferences;
|
||||||
const favPronouns = data.pronouns.filter((entry) => entry.status === WordStatus.Favourite);
|
$: mergedPreferences = Object.assign(defaultPreferences, data.custom_preferences);
|
||||||
|
|
||||||
|
let favNames: FieldEntry[];
|
||||||
|
$: favNames = data.names.filter(
|
||||||
|
(entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite,
|
||||||
|
);
|
||||||
|
let favPronouns: Pronoun[];
|
||||||
|
$: favPronouns = data.pronouns.filter(
|
||||||
|
(entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite,
|
||||||
|
);
|
||||||
|
|
||||||
let profileEmpty = false;
|
let profileEmpty = false;
|
||||||
$: profileEmpty =
|
$: profileEmpty =
|
||||||
|
@ -146,7 +158,11 @@
|
||||||
<h3>Names</h3>
|
<h3>Names</h3>
|
||||||
<ul class="list-unstyled fs-5">
|
<ul class="list-unstyled fs-5">
|
||||||
{#each data.names as name}
|
{#each data.names as name}
|
||||||
<li><StatusLine status={name.status}>{name.value}</StatusLine></li>
|
<li>
|
||||||
|
<StatusLine preferences={data.custom_preferences} status={name.status}
|
||||||
|
>{name.value}</StatusLine
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -156,14 +172,18 @@
|
||||||
<h3>Pronouns</h3>
|
<h3>Pronouns</h3>
|
||||||
<ul class="list-unstyled fs-5">
|
<ul class="list-unstyled fs-5">
|
||||||
{#each data.pronouns as pronouns}
|
{#each data.pronouns as pronouns}
|
||||||
<li><StatusLine status={pronouns.status}><PronounLink {pronouns} /></StatusLine></li>
|
<li>
|
||||||
|
<StatusLine preferences={data.custom_preferences} status={pronouns.status}
|
||||||
|
><PronounLink {pronouns} /></StatusLine
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each data.fields as field}
|
{#each data.fields as field}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<FieldCard {field} />
|
<FieldCard preferences={data.custom_preferences} {field} />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,25 +2,40 @@
|
||||||
import FieldCard from "$lib/components/FieldCard.svelte";
|
import FieldCard from "$lib/components/FieldCard.svelte";
|
||||||
|
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
import StatusIcon from "$lib/components/StatusIcon.svelte";
|
|
||||||
import PronounLink from "$lib/components/PronounLink.svelte";
|
import PronounLink from "$lib/components/PronounLink.svelte";
|
||||||
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
||||||
import { Alert, Button, Icon } from "sveltestrap";
|
import { Alert, Button, Icon } from "sveltestrap";
|
||||||
import { memberAvatars, pronounDisplay, WordStatus } from "$lib/api/entities";
|
import {
|
||||||
|
memberAvatars,
|
||||||
|
pronounDisplay,
|
||||||
|
type CustomPreferences,
|
||||||
|
type FieldEntry,
|
||||||
|
type Pronoun,
|
||||||
|
} from "$lib/api/entities";
|
||||||
import { PUBLIC_BASE_URL } from "$env/static/public";
|
import { PUBLIC_BASE_URL } from "$env/static/public";
|
||||||
import { userStore } from "$lib/store";
|
import { userStore } from "$lib/store";
|
||||||
import { renderMarkdown } from "$lib/utils";
|
import { renderMarkdown } from "$lib/utils";
|
||||||
import ReportButton from "../ReportButton.svelte";
|
import ReportButton from "../ReportButton.svelte";
|
||||||
import ProfileLink from "../ProfileLink.svelte";
|
import ProfileLink from "../ProfileLink.svelte";
|
||||||
import StatusLine from "$lib/components/StatusLine.svelte";
|
import StatusLine from "$lib/components/StatusLine.svelte";
|
||||||
|
import defaultPreferences from "$lib/api/default_preferences";
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
let bio: string | null;
|
let bio: string | null;
|
||||||
$: bio = renderMarkdown(data.bio);
|
$: bio = renderMarkdown(data.bio);
|
||||||
|
|
||||||
const favNames = data.names.filter((entry) => entry.status === WordStatus.Favourite);
|
let mergedPreferences: CustomPreferences;
|
||||||
const favPronouns = data.pronouns.filter((entry) => entry.status === WordStatus.Favourite);
|
$: mergedPreferences = Object.assign(defaultPreferences, data.user.custom_preferences);
|
||||||
|
|
||||||
|
let favNames: FieldEntry[];
|
||||||
|
$: favNames = data.names.filter(
|
||||||
|
(entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite,
|
||||||
|
);
|
||||||
|
let favPronouns: Pronoun[];
|
||||||
|
$: favPronouns = data.pronouns.filter(
|
||||||
|
(entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite,
|
||||||
|
);
|
||||||
|
|
||||||
let profileEmpty = false;
|
let profileEmpty = false;
|
||||||
$: profileEmpty =
|
$: profileEmpty =
|
||||||
|
@ -81,7 +96,11 @@
|
||||||
<h3>Names</h3>
|
<h3>Names</h3>
|
||||||
<ul class="list-unstyled fs-5">
|
<ul class="list-unstyled fs-5">
|
||||||
{#each data.names as name}
|
{#each data.names as name}
|
||||||
<li><StatusLine status={name.status}>{name.value}</StatusLine></li>
|
<li>
|
||||||
|
<StatusLine preferences={data.user.custom_preferences} status={name.status}
|
||||||
|
>{name.value}</StatusLine
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -91,14 +110,18 @@
|
||||||
<h3>Pronouns</h3>
|
<h3>Pronouns</h3>
|
||||||
<ul class="list-unstyled fs-5">
|
<ul class="list-unstyled fs-5">
|
||||||
{#each data.pronouns as pronouns}
|
{#each data.pronouns as pronouns}
|
||||||
<li><StatusLine status={pronouns.status}><PronounLink {pronouns} /></StatusLine></li>
|
<li>
|
||||||
|
<StatusLine preferences={data.user.custom_preferences} status={pronouns.status}
|
||||||
|
><PronounLink {pronouns} /></StatusLine
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each data.fields as field}
|
{#each data.fields as field}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<FieldCard {field} />
|
<FieldCard preferences={data.user.custom_preferences} {field} />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,19 +1,20 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { WordStatus, type Field } from "$lib/api/entities";
|
import type { Field, CustomPreferences } from "$lib/api/entities";
|
||||||
import IconButton from "$lib/components/IconButton.svelte";
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
import { Button, Input, InputGroup } from "sveltestrap";
|
import { Button, Input, InputGroup } from "sveltestrap";
|
||||||
import FieldEntry from "./FieldEntry.svelte";
|
import FieldEntry from "./FieldEntry.svelte";
|
||||||
|
|
||||||
export let field: Field;
|
export let field: Field;
|
||||||
|
export let preferences: CustomPreferences;
|
||||||
export let deleteField: () => void;
|
export let deleteField: () => void;
|
||||||
export let moveField: (up: boolean) => void;
|
export let moveField: (up: boolean) => void;
|
||||||
|
|
||||||
let newEntry: string = "";
|
let newEntry = "";
|
||||||
|
|
||||||
const addEntry = (event: Event) => {
|
const addEntry = (event: Event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
field.entries = [...field.entries, { value: newEntry, status: WordStatus.Okay }];
|
field.entries = [...field.entries, { value: newEntry, status: "missing" }];
|
||||||
newEntry = "";
|
newEntry = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -57,6 +58,7 @@
|
||||||
<FieldEntry
|
<FieldEntry
|
||||||
bind:value={field.entries[index].value}
|
bind:value={field.entries[index].value}
|
||||||
bind:status={field.entries[index].status}
|
bind:status={field.entries[index].status}
|
||||||
|
{preferences}
|
||||||
moveUp={() => moveEntry(index, true)}
|
moveUp={() => moveEntry(index, true)}
|
||||||
moveDown={() => moveEntry(index, false)}
|
moveDown={() => moveEntry(index, false)}
|
||||||
remove={() => removeEntry(index)}
|
remove={() => removeEntry(index)}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { WordStatus } from "$lib/api/entities";
|
import defaultPreferences from "$lib/api/default_preferences";
|
||||||
|
import type { CustomPreference, CustomPreferences } from "$lib/api/entities";
|
||||||
import IconButton from "$lib/components/IconButton.svelte";
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
import {
|
import {
|
||||||
ButtonDropdown,
|
ButtonDropdown,
|
||||||
|
@ -11,46 +12,23 @@
|
||||||
} from "sveltestrap";
|
} from "sveltestrap";
|
||||||
|
|
||||||
export let value: string;
|
export let value: string;
|
||||||
export let status: WordStatus;
|
export let status: string;
|
||||||
|
export let preferences: CustomPreferences;
|
||||||
export let moveUp: () => void;
|
export let moveUp: () => void;
|
||||||
export let moveDown: () => void;
|
export let moveDown: () => void;
|
||||||
export let remove: () => void;
|
export let remove: () => void;
|
||||||
|
|
||||||
let buttonElement: HTMLElement;
|
let buttonElement: HTMLElement;
|
||||||
|
|
||||||
const iconFor = (wordStatus: WordStatus) => {
|
let mergedPreferences: CustomPreferences;
|
||||||
switch (wordStatus) {
|
$: mergedPreferences = Object.assign(defaultPreferences, preferences);
|
||||||
case WordStatus.Favourite:
|
|
||||||
return "heart-fill";
|
|
||||||
case WordStatus.Okay:
|
|
||||||
return "hand-thumbs-up";
|
|
||||||
case WordStatus.Jokingly:
|
|
||||||
return "emoji-laughing";
|
|
||||||
case WordStatus.FriendsOnly:
|
|
||||||
return "people";
|
|
||||||
case WordStatus.Avoid:
|
|
||||||
return "hand-thumbs-down";
|
|
||||||
default:
|
|
||||||
return "hand-thumbs-up";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const textFor = (wordStatus: WordStatus) => {
|
let currentPreference: CustomPreference;
|
||||||
switch (wordStatus) {
|
$: currentPreference =
|
||||||
case WordStatus.Favourite:
|
status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing;
|
||||||
return "Favourite";
|
|
||||||
case WordStatus.Okay:
|
let preferenceIds: string[];
|
||||||
return "Okay";
|
$: preferenceIds = Object.keys(mergedPreferences).filter((s) => s !== "missing");
|
||||||
case WordStatus.Jokingly:
|
|
||||||
return "Jokingly";
|
|
||||||
case WordStatus.FriendsOnly:
|
|
||||||
return "Friends only";
|
|
||||||
case WordStatus.Avoid:
|
|
||||||
return "Avoid";
|
|
||||||
default:
|
|
||||||
return "Okay";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="input-group m-1">
|
<div class="input-group m-1">
|
||||||
|
@ -58,30 +36,17 @@
|
||||||
<IconButton icon="chevron-down" color="secondary" tooltip="Move name down" click={moveDown} />
|
<IconButton icon="chevron-down" color="secondary" tooltip="Move name down" click={moveDown} />
|
||||||
<input type="text" class="form-control" bind:value />
|
<input type="text" class="form-control" bind:value />
|
||||||
<ButtonDropdown>
|
<ButtonDropdown>
|
||||||
<Tooltip target={buttonElement} placement="top">{textFor(status)}</Tooltip>
|
<Tooltip target={buttonElement} placement="top">{currentPreference.tooltip}</Tooltip>
|
||||||
<DropdownToggle color="secondary" caret bind:inner={buttonElement}>
|
<DropdownToggle color="secondary" caret bind:inner={buttonElement}>
|
||||||
<Icon name={iconFor(status)} />
|
<Icon name={currentPreference.icon} />
|
||||||
</DropdownToggle>
|
</DropdownToggle>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownItem
|
{#each preferenceIds as id}
|
||||||
on:click={() => (status = WordStatus.Favourite)}
|
<DropdownItem on:click={() => (status = id)} active={status === id}>
|
||||||
active={status === WordStatus.Favourite}>Favourite</DropdownItem
|
<Icon name={mergedPreferences[id].icon} aria-hidden />
|
||||||
>
|
{mergedPreferences[id].tooltip}
|
||||||
<DropdownItem on:click={() => (status = WordStatus.Okay)} active={status === WordStatus.Okay}
|
</DropdownItem>
|
||||||
>Okay</DropdownItem
|
{/each}
|
||||||
>
|
|
||||||
<DropdownItem
|
|
||||||
on:click={() => (status = WordStatus.Jokingly)}
|
|
||||||
active={status === WordStatus.Jokingly}>Jokingly</DropdownItem
|
|
||||||
>
|
|
||||||
<DropdownItem
|
|
||||||
on:click={() => (status = WordStatus.FriendsOnly)}
|
|
||||||
active={status === WordStatus.FriendsOnly}>Friends only</DropdownItem
|
|
||||||
>
|
|
||||||
<DropdownItem
|
|
||||||
on:click={() => (status = WordStatus.Avoid)}
|
|
||||||
active={status === WordStatus.Avoid}>Avoid</DropdownItem
|
|
||||||
>
|
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</ButtonDropdown>
|
</ButtonDropdown>
|
||||||
<IconButton color="danger" icon="trash3" tooltip="Remove name" click={remove} />
|
<IconButton color="danger" icon="trash3" tooltip="Remove name" click={remove} />
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { WordStatus, type Pronoun, pronounDisplay } from "$lib/api/entities";
|
import defaultPreferences from "$lib/api/default_preferences";
|
||||||
|
import type { Pronoun, CustomPreference, CustomPreferences } from "$lib/api/entities";
|
||||||
import IconButton from "$lib/components/IconButton.svelte";
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
ButtonDropdown,
|
ButtonDropdown,
|
||||||
Collapse,
|
Collapse,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
|
@ -15,6 +15,7 @@
|
||||||
} from "sveltestrap";
|
} from "sveltestrap";
|
||||||
|
|
||||||
export let pronoun: Pronoun;
|
export let pronoun: Pronoun;
|
||||||
|
export let preferences: CustomPreferences;
|
||||||
export let moveUp: () => void;
|
export let moveUp: () => void;
|
||||||
export let moveDown: () => void;
|
export let moveDown: () => void;
|
||||||
export let remove: () => void;
|
export let remove: () => void;
|
||||||
|
@ -23,39 +24,17 @@
|
||||||
let displayOpen = false;
|
let displayOpen = false;
|
||||||
const toggleDisplay = () => (displayOpen = !displayOpen);
|
const toggleDisplay = () => (displayOpen = !displayOpen);
|
||||||
|
|
||||||
const iconFor = (wordStatus: WordStatus) => {
|
let mergedPreferences: CustomPreferences;
|
||||||
switch (wordStatus) {
|
$: mergedPreferences = Object.assign(defaultPreferences, preferences);
|
||||||
case WordStatus.Favourite:
|
|
||||||
return "heart-fill";
|
|
||||||
case WordStatus.Okay:
|
|
||||||
return "hand-thumbs-up";
|
|
||||||
case WordStatus.Jokingly:
|
|
||||||
return "emoji-laughing";
|
|
||||||
case WordStatus.FriendsOnly:
|
|
||||||
return "people";
|
|
||||||
case WordStatus.Avoid:
|
|
||||||
return "hand-thumbs-down";
|
|
||||||
default:
|
|
||||||
return "hand-thumbs-up";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const textFor = (wordStatus: WordStatus) => {
|
let currentPreference: CustomPreference;
|
||||||
switch (wordStatus) {
|
$: currentPreference =
|
||||||
case WordStatus.Favourite:
|
pronoun.status in mergedPreferences
|
||||||
return "Favourite";
|
? mergedPreferences[pronoun.status]
|
||||||
case WordStatus.Okay:
|
: defaultPreferences.missing;
|
||||||
return "Okay";
|
|
||||||
case WordStatus.Jokingly:
|
let preferenceIds: string[];
|
||||||
return "Jokingly";
|
$: preferenceIds = Object.keys(mergedPreferences).filter((s) => s !== "missing");
|
||||||
case WordStatus.FriendsOnly:
|
|
||||||
return "Friends only";
|
|
||||||
case WordStatus.Avoid:
|
|
||||||
return "Avoid";
|
|
||||||
default:
|
|
||||||
return "Okay";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="input-group m-1">
|
<div class="input-group m-1">
|
||||||
|
@ -75,31 +54,17 @@
|
||||||
click={toggleDisplay}
|
click={toggleDisplay}
|
||||||
/>
|
/>
|
||||||
<ButtonDropdown>
|
<ButtonDropdown>
|
||||||
<Tooltip target={buttonElement} placement="top">{textFor(pronoun.status)}</Tooltip>
|
<Tooltip target={buttonElement} placement="top">{currentPreference.tooltip}</Tooltip>
|
||||||
<DropdownToggle color="secondary" caret bind:inner={buttonElement}>
|
<DropdownToggle color="secondary" caret bind:inner={buttonElement}>
|
||||||
<Icon name={iconFor(pronoun.status)} />
|
<Icon name={currentPreference.icon} />
|
||||||
</DropdownToggle>
|
</DropdownToggle>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownItem
|
{#each preferenceIds as id}
|
||||||
on:click={() => (pronoun.status = WordStatus.Favourite)}
|
<DropdownItem on:click={() => (pronoun.status = id)} active={pronoun.status === id}>
|
||||||
active={pronoun.status === WordStatus.Favourite}>Favourite</DropdownItem
|
<Icon name={mergedPreferences[id].icon} aria-hidden />
|
||||||
>
|
{mergedPreferences[id].tooltip}
|
||||||
<DropdownItem
|
</DropdownItem>
|
||||||
on:click={() => (pronoun.status = WordStatus.Okay)}
|
{/each}
|
||||||
active={pronoun.status === WordStatus.Okay}>Okay</DropdownItem
|
|
||||||
>
|
|
||||||
<DropdownItem
|
|
||||||
on:click={() => (pronoun.status = WordStatus.Jokingly)}
|
|
||||||
active={pronoun.status === WordStatus.Jokingly}>Jokingly</DropdownItem
|
|
||||||
>
|
|
||||||
<DropdownItem
|
|
||||||
on:click={() => (pronoun.status = WordStatus.FriendsOnly)}
|
|
||||||
active={pronoun.status === WordStatus.FriendsOnly}>Friends only</DropdownItem
|
|
||||||
>
|
|
||||||
<DropdownItem
|
|
||||||
on:click={() => (pronoun.status = WordStatus.Avoid)}
|
|
||||||
active={pronoun.status === WordStatus.Avoid}>Avoid</DropdownItem
|
|
||||||
>
|
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</ButtonDropdown>
|
</ButtonDropdown>
|
||||||
<IconButton color="danger" icon="trash3" tooltip="Remove pronouns" click={remove} />
|
<IconButton color="danger" icon="trash3" tooltip="Remove pronouns" click={remove} />
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { WordStatus } from "$lib/api/entities";
|
import defaultPreferences from "$lib/api/default_preferences";
|
||||||
|
import type { CustomPreference, CustomPreferences } from "$lib/api/entities";
|
||||||
import IconButton from "$lib/components/IconButton.svelte";
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
import {
|
import {
|
||||||
ButtonDropdown,
|
ButtonDropdown,
|
||||||
|
@ -11,46 +12,23 @@
|
||||||
} from "sveltestrap";
|
} from "sveltestrap";
|
||||||
|
|
||||||
export let value: string;
|
export let value: string;
|
||||||
export let status: WordStatus;
|
export let status: string;
|
||||||
|
export let preferences: CustomPreferences;
|
||||||
export let moveUp: () => void;
|
export let moveUp: () => void;
|
||||||
export let moveDown: () => void;
|
export let moveDown: () => void;
|
||||||
export let remove: () => void;
|
export let remove: () => void;
|
||||||
|
|
||||||
let buttonElement: HTMLElement;
|
let buttonElement: HTMLElement;
|
||||||
|
|
||||||
const iconFor = (wordStatus: WordStatus) => {
|
let mergedPreferences: CustomPreferences;
|
||||||
switch (wordStatus) {
|
$: mergedPreferences = Object.assign(defaultPreferences, preferences);
|
||||||
case WordStatus.Favourite:
|
|
||||||
return "heart-fill";
|
|
||||||
case WordStatus.Okay:
|
|
||||||
return "hand-thumbs-up";
|
|
||||||
case WordStatus.Jokingly:
|
|
||||||
return "emoji-laughing";
|
|
||||||
case WordStatus.FriendsOnly:
|
|
||||||
return "people";
|
|
||||||
case WordStatus.Avoid:
|
|
||||||
return "hand-thumbs-down";
|
|
||||||
default:
|
|
||||||
return "hand-thumbs-up";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const textFor = (wordStatus: WordStatus) => {
|
let currentPreference: CustomPreference;
|
||||||
switch (wordStatus) {
|
$: currentPreference =
|
||||||
case WordStatus.Favourite:
|
status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing;
|
||||||
return "Favourite";
|
|
||||||
case WordStatus.Okay:
|
let preferenceIds: string[];
|
||||||
return "Okay";
|
$: preferenceIds = Object.keys(mergedPreferences).filter((s) => s !== "missing");
|
||||||
case WordStatus.Jokingly:
|
|
||||||
return "Jokingly";
|
|
||||||
case WordStatus.FriendsOnly:
|
|
||||||
return "Friends only";
|
|
||||||
case WordStatus.Avoid:
|
|
||||||
return "Avoid";
|
|
||||||
default:
|
|
||||||
return "Okay";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="input-group m-1">
|
<div class="input-group m-1">
|
||||||
|
@ -58,30 +36,17 @@
|
||||||
<IconButton icon="chevron-down" color="secondary" tooltip="Move entry down" click={moveDown} />
|
<IconButton icon="chevron-down" color="secondary" tooltip="Move entry down" click={moveDown} />
|
||||||
<input type="text" class="form-control" bind:value />
|
<input type="text" class="form-control" bind:value />
|
||||||
<ButtonDropdown>
|
<ButtonDropdown>
|
||||||
<Tooltip target={buttonElement} placement="top">{textFor(status)}</Tooltip>
|
<Tooltip target={buttonElement} placement="top">{currentPreference.tooltip}</Tooltip>
|
||||||
<DropdownToggle color="secondary" caret bind:inner={buttonElement}>
|
<DropdownToggle color="secondary" caret bind:inner={buttonElement}>
|
||||||
<Icon name={iconFor(status)} />
|
<Icon name={currentPreference.icon} />
|
||||||
</DropdownToggle>
|
</DropdownToggle>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownItem
|
{#each preferenceIds as id}
|
||||||
on:click={() => (status = WordStatus.Favourite)}
|
<DropdownItem on:click={() => (status = id)} active={status === id}>
|
||||||
active={status === WordStatus.Favourite}>Favourite</DropdownItem
|
<Icon name={mergedPreferences[id].icon} aria-hidden />
|
||||||
>
|
{mergedPreferences[id].tooltip}
|
||||||
<DropdownItem on:click={() => (status = WordStatus.Okay)} active={status === WordStatus.Okay}
|
</DropdownItem>
|
||||||
>Okay</DropdownItem
|
{/each}
|
||||||
>
|
|
||||||
<DropdownItem
|
|
||||||
on:click={() => (status = WordStatus.Jokingly)}
|
|
||||||
active={status === WordStatus.Jokingly}>Jokingly</DropdownItem
|
|
||||||
>
|
|
||||||
<DropdownItem
|
|
||||||
on:click={() => (status = WordStatus.FriendsOnly)}
|
|
||||||
active={status === WordStatus.FriendsOnly}>Friends only</DropdownItem
|
|
||||||
>
|
|
||||||
<DropdownItem
|
|
||||||
on:click={() => (status = WordStatus.Avoid)}
|
|
||||||
active={status === WordStatus.Avoid}>Avoid</DropdownItem
|
|
||||||
>
|
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</ButtonDropdown>
|
</ButtonDropdown>
|
||||||
<IconButton color="danger" icon="trash3" tooltip="Remove entry" click={remove} />
|
<IconButton color="danger" icon="trash3" tooltip="Remove entry" click={remove} />
|
||||||
|
|
|
@ -153,10 +153,9 @@
|
||||||
if (list[0].size > MAX_AVATAR_BYTES) {
|
if (list[0].size > MAX_AVATAR_BYTES) {
|
||||||
addToast({
|
addToast({
|
||||||
header: "Avatar too large",
|
header: "Avatar too large",
|
||||||
body:
|
body: `This avatar is too large, please resize it (maximum is ${prettyBytes(
|
||||||
`This avatar is too large, please resize it (maximum is ${prettyBytes(
|
MAX_AVATAR_BYTES,
|
||||||
MAX_AVATAR_BYTES,
|
)}, the file you tried to upload is ${prettyBytes(list[0].size)})`,
|
||||||
)}, the file you tried to upload is ${prettyBytes(list[0].size)})`,
|
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -440,6 +439,7 @@
|
||||||
<EditableName
|
<EditableName
|
||||||
bind:value={names[index].value}
|
bind:value={names[index].value}
|
||||||
bind:status={names[index].status}
|
bind:status={names[index].status}
|
||||||
|
preferences={data.user.custom_preferences}
|
||||||
moveUp={() => moveName(index, true)}
|
moveUp={() => moveName(index, true)}
|
||||||
moveDown={() => moveName(index, false)}
|
moveDown={() => moveName(index, false)}
|
||||||
remove={() => removeName(index)}
|
remove={() => removeName(index)}
|
||||||
|
@ -479,6 +479,7 @@
|
||||||
{#each pronouns as _, index}
|
{#each pronouns as _, index}
|
||||||
<EditablePronouns
|
<EditablePronouns
|
||||||
bind:pronoun={pronouns[index]}
|
bind:pronoun={pronouns[index]}
|
||||||
|
preferences={data.user.custom_preferences}
|
||||||
moveUp={() => movePronoun(index, true)}
|
moveUp={() => movePronoun(index, true)}
|
||||||
moveDown={() => movePronoun(index, false)}
|
moveDown={() => movePronoun(index, false)}
|
||||||
remove={() => removePronoun(index)}
|
remove={() => removePronoun(index)}
|
||||||
|
@ -520,6 +521,7 @@
|
||||||
{#each fields as _, index}
|
{#each fields as _, index}
|
||||||
<EditableField
|
<EditableField
|
||||||
bind:field={fields[index]}
|
bind:field={fields[index]}
|
||||||
|
preferences={data.user.custom_preferences}
|
||||||
deleteField={() => removeField(index)}
|
deleteField={() => removeField(index)}
|
||||||
moveField={(up) => moveField(index, up)}
|
moveField={(up) => moveField(index, up)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -8,6 +8,8 @@
|
||||||
type FieldEntry,
|
type FieldEntry,
|
||||||
type MeUser,
|
type MeUser,
|
||||||
type Pronoun,
|
type Pronoun,
|
||||||
|
PreferenceSize,
|
||||||
|
type CustomPreferences,
|
||||||
} from "$lib/api/entities";
|
} from "$lib/api/entities";
|
||||||
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
||||||
import { userStore } from "$lib/store";
|
import { userStore } from "$lib/store";
|
||||||
|
@ -37,6 +39,7 @@
|
||||||
import { charCount, renderMarkdown } from "$lib/utils";
|
import { charCount, renderMarkdown } from "$lib/utils";
|
||||||
import MarkdownHelp from "../MarkdownHelp.svelte";
|
import MarkdownHelp from "../MarkdownHelp.svelte";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import CustomPreference from "./CustomPreference.svelte";
|
||||||
|
|
||||||
const MAX_AVATAR_BYTES = 1_000_000;
|
const MAX_AVATAR_BYTES = 1_000_000;
|
||||||
|
|
||||||
|
@ -52,6 +55,7 @@
|
||||||
let pronouns: Pronoun[] = window.structuredClone(data.user.pronouns);
|
let pronouns: Pronoun[] = window.structuredClone(data.user.pronouns);
|
||||||
let fields: Field[] = window.structuredClone(data.user.fields);
|
let fields: Field[] = window.structuredClone(data.user.fields);
|
||||||
let list_private = data.user.list_private;
|
let list_private = data.user.list_private;
|
||||||
|
let custom_preferences = window.structuredClone(data.user.custom_preferences);
|
||||||
|
|
||||||
let avatar: string | null;
|
let avatar: string | null;
|
||||||
let avatar_files: FileList | null;
|
let avatar_files: FileList | null;
|
||||||
|
@ -60,6 +64,9 @@
|
||||||
let newPronouns = "";
|
let newPronouns = "";
|
||||||
let newLink = "";
|
let newLink = "";
|
||||||
|
|
||||||
|
let preferenceIds: string[];
|
||||||
|
$: preferenceIds = Object.keys(custom_preferences);
|
||||||
|
|
||||||
let modified = false;
|
let modified = false;
|
||||||
|
|
||||||
$: modified = isModified(
|
$: modified = isModified(
|
||||||
|
@ -73,6 +80,7 @@
|
||||||
avatar,
|
avatar,
|
||||||
member_title,
|
member_title,
|
||||||
list_private,
|
list_private,
|
||||||
|
custom_preferences,
|
||||||
);
|
);
|
||||||
$: getAvatar(avatar_files).then((b64) => (avatar = b64));
|
$: getAvatar(avatar_files).then((b64) => (avatar = b64));
|
||||||
|
|
||||||
|
@ -87,6 +95,7 @@
|
||||||
avatar: string | null,
|
avatar: string | null,
|
||||||
member_title: string,
|
member_title: string,
|
||||||
list_private: boolean,
|
list_private: boolean,
|
||||||
|
custom_preferences: CustomPreferences,
|
||||||
) => {
|
) => {
|
||||||
if (bio !== (user.bio || "")) return true;
|
if (bio !== (user.bio || "")) return true;
|
||||||
if (display_name !== (user.display_name || "")) return true;
|
if (display_name !== (user.display_name || "")) return true;
|
||||||
|
@ -95,6 +104,7 @@
|
||||||
if (!fieldsEqual(fields, user.fields)) return true;
|
if (!fieldsEqual(fields, user.fields)) return true;
|
||||||
if (!namesEqual(names, user.names)) return true;
|
if (!namesEqual(names, user.names)) return true;
|
||||||
if (!pronounsEqual(pronouns, user.pronouns)) return true;
|
if (!pronounsEqual(pronouns, user.pronouns)) return true;
|
||||||
|
if (!customPreferencesEqual(custom_preferences, user.custom_preferences)) return true;
|
||||||
if (avatar !== null) return true;
|
if (avatar !== null) return true;
|
||||||
if (list_private !== user.list_private) return true;
|
if (list_private !== user.list_private) return true;
|
||||||
|
|
||||||
|
@ -136,6 +146,21 @@
|
||||||
return arr1.every((_, i) => arr1[i] === arr2[i]);
|
return arr1.every((_, i) => arr1[i] === arr2[i]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const customPreferencesEqual = (obj1: CustomPreferences, obj2: CustomPreferences) => {
|
||||||
|
return Object.keys(obj1)
|
||||||
|
.map((key) => {
|
||||||
|
if (!(key in obj2)) return false;
|
||||||
|
return (
|
||||||
|
obj1[key].icon === obj2[key].icon &&
|
||||||
|
obj1[key].tooltip === obj2[key].tooltip &&
|
||||||
|
obj1[key].favourite === obj2[key].favourite &&
|
||||||
|
obj1[key].muted === obj2[key].muted &&
|
||||||
|
obj1[key].size === obj2[key].size
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.every((entry) => entry);
|
||||||
|
};
|
||||||
|
|
||||||
const getAvatar = async (list: FileList | null) => {
|
const getAvatar = async (list: FileList | null) => {
|
||||||
if (!list || list.length === 0) return null;
|
if (!list || list.length === 0) return null;
|
||||||
if (list[0].size > MAX_AVATAR_BYTES) {
|
if (list[0].size > MAX_AVATAR_BYTES) {
|
||||||
|
@ -226,6 +251,19 @@
|
||||||
newLink = "";
|
newLink = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addPreference = () => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
custom_preferences[id] = {
|
||||||
|
icon: "question",
|
||||||
|
tooltip: "New preference",
|
||||||
|
size: PreferenceSize.Normal,
|
||||||
|
muted: false,
|
||||||
|
favourite: false,
|
||||||
|
};
|
||||||
|
custom_preferences = custom_preferences;
|
||||||
|
};
|
||||||
|
|
||||||
const removeName = (index: number) => {
|
const removeName = (index: number) => {
|
||||||
names.splice(index, 1);
|
names.splice(index, 1);
|
||||||
names = [...names];
|
names = [...names];
|
||||||
|
@ -246,6 +284,11 @@
|
||||||
fields = [...fields];
|
fields = [...fields];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removePreference = (id: string) => {
|
||||||
|
delete custom_preferences[id];
|
||||||
|
custom_preferences = custom_preferences;
|
||||||
|
};
|
||||||
|
|
||||||
const updateUser = async () => {
|
const updateUser = async () => {
|
||||||
const toastId = addToast({
|
const toastId = addToast({
|
||||||
header: "Saving changes",
|
header: "Saving changes",
|
||||||
|
@ -264,6 +307,7 @@
|
||||||
fields,
|
fields,
|
||||||
member_title,
|
member_title,
|
||||||
list_private,
|
list_private,
|
||||||
|
custom_preferences,
|
||||||
});
|
});
|
||||||
|
|
||||||
data.user = resp;
|
data.user = resp;
|
||||||
|
@ -367,6 +411,7 @@
|
||||||
<EditableName
|
<EditableName
|
||||||
bind:value={names[index].value}
|
bind:value={names[index].value}
|
||||||
bind:status={names[index].status}
|
bind:status={names[index].status}
|
||||||
|
preferences={data.user.custom_preferences}
|
||||||
moveUp={() => moveName(index, true)}
|
moveUp={() => moveName(index, true)}
|
||||||
moveDown={() => moveName(index, false)}
|
moveDown={() => moveName(index, false)}
|
||||||
remove={() => removeName(index)}
|
remove={() => removeName(index)}
|
||||||
|
@ -447,6 +492,7 @@
|
||||||
{#each fields as _, index}
|
{#each fields as _, index}
|
||||||
<EditableField
|
<EditableField
|
||||||
bind:field={fields[index]}
|
bind:field={fields[index]}
|
||||||
|
preferences={data.user.custom_preferences}
|
||||||
deleteField={() => removeField(index)}
|
deleteField={() => removeField(index)}
|
||||||
moveField={(up) => moveField(index, up)}
|
moveField={(up) => moveField(index, up)}
|
||||||
/>
|
/>
|
||||||
|
@ -478,7 +524,7 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</TabPane>
|
</TabPane>
|
||||||
<TabPane tabId="other" tab="Other">
|
<TabPane tabId="other" tab="Preferences & other">
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<FormGroup floating label={'"Members" header text'}>
|
<FormGroup floating label={'"Members" header text'}>
|
||||||
|
@ -510,5 +556,18 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
Preferences <Button on:click={addPreference} color="success"
|
||||||
|
><Icon name="plus" aria-hidden /> Add new</Button
|
||||||
|
>
|
||||||
|
</h3>
|
||||||
|
{#each preferenceIds as id}
|
||||||
|
<CustomPreference
|
||||||
|
bind:preference={custom_preferences[id]}
|
||||||
|
remove={() => removePreference(id)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</TabPane>
|
</TabPane>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
|
91
frontend/src/routes/edit/profile/CustomPreference.svelte
Normal file
91
frontend/src/routes/edit/profile/CustomPreference.svelte
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { PreferenceSize, type CustomPreference } from "$lib/api/entities";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
import {
|
||||||
|
ButtonDropdown,
|
||||||
|
DropdownItem,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownToggle,
|
||||||
|
Icon,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
Tooltip,
|
||||||
|
} from "sveltestrap";
|
||||||
|
import icons from "../../../icons";
|
||||||
|
|
||||||
|
export let preference: CustomPreference;
|
||||||
|
export let remove: () => void;
|
||||||
|
|
||||||
|
let iconButton: HTMLElement;
|
||||||
|
let sizeButton: HTMLElement;
|
||||||
|
|
||||||
|
const toggleMuted = () => (preference.muted = !preference.muted);
|
||||||
|
const toggleFavourite = () => (preference.favourite = !preference.favourite);
|
||||||
|
|
||||||
|
let searchBox = "";
|
||||||
|
let filteredIcons: string[] = [];
|
||||||
|
$: filteredIcons = searchBox
|
||||||
|
? icons
|
||||||
|
.filter((icon) => icon.startsWith(searchBox))
|
||||||
|
.sort()
|
||||||
|
.slice(0, 15)
|
||||||
|
: [];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<InputGroup class="m-1">
|
||||||
|
<ButtonDropdown>
|
||||||
|
<Tooltip target={iconButton} placement="top">Change icon</Tooltip>
|
||||||
|
<DropdownToggle color="secondary" caret bind:inner={iconButton}>
|
||||||
|
<Icon name={preference.icon} alt="Current icon" />
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu>
|
||||||
|
<p class="px-2">
|
||||||
|
<Input type="text" placeholder="Search for icons" bind:value={searchBox} />
|
||||||
|
</p>
|
||||||
|
<DropdownItem divider />
|
||||||
|
{#each filteredIcons as icon}
|
||||||
|
<DropdownItem active={preference.icon === icon} on:click={() => (preference.icon = icon)}
|
||||||
|
><Icon name={icon} alt="Icon: {icon}" /> {icon}</DropdownItem
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<p class="px-2">Start typing to filter</p>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenu>
|
||||||
|
</ButtonDropdown>
|
||||||
|
<input type="text" class="form-control" bind:value={preference.tooltip} />
|
||||||
|
<Tooltip target={sizeButton} placement="top">Change text size</Tooltip>
|
||||||
|
<ButtonDropdown>
|
||||||
|
<DropdownToggle color="secondary" caret bind:inner={sizeButton}>
|
||||||
|
<Icon name="type" alt="Text size" />
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownItem
|
||||||
|
active={preference.size === PreferenceSize.Large}
|
||||||
|
on:click={() => (preference.size = PreferenceSize.Large)}>Large</DropdownItem
|
||||||
|
>
|
||||||
|
<DropdownItem
|
||||||
|
active={preference.size === PreferenceSize.Normal}
|
||||||
|
on:click={() => (preference.size = PreferenceSize.Normal)}>Medium</DropdownItem
|
||||||
|
>
|
||||||
|
<DropdownItem
|
||||||
|
active={preference.size === PreferenceSize.Small}
|
||||||
|
on:click={() => (preference.size = PreferenceSize.Small)}>Small</DropdownItem
|
||||||
|
>
|
||||||
|
</DropdownMenu>
|
||||||
|
</ButtonDropdown>
|
||||||
|
<IconButton
|
||||||
|
color="secondary"
|
||||||
|
icon={preference.favourite ? "star-fill" : "star"}
|
||||||
|
click={toggleFavourite}
|
||||||
|
active={preference.favourite}
|
||||||
|
tooltip="Treat like favourite"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
color="secondary"
|
||||||
|
icon="fonts"
|
||||||
|
click={toggleMuted}
|
||||||
|
active={preference.muted}
|
||||||
|
tooltip="Show as muted text"
|
||||||
|
/>
|
||||||
|
<IconButton color="danger" icon="trash3" tooltip="Remove preference" click={remove} />
|
||||||
|
</InputGroup>
|
|
@ -31,7 +31,7 @@
|
||||||
|
|
||||||
let theme: string;
|
let theme: string;
|
||||||
let currentUser: MeUser | null;
|
let currentUser: MeUser | null;
|
||||||
let showMenu: boolean = false;
|
let showMenu = false;
|
||||||
|
|
||||||
let isAdmin = false;
|
let isAdmin = false;
|
||||||
let numReports = 0;
|
let numReports = 0;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Report } from "$lib/api/entities";
|
import type { Report } from "$lib/api/entities";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { Button, Card, CardBody, CardFooter, CardHeader } from "sveltestrap";
|
import { Card, CardBody, CardFooter, CardHeader } from "sveltestrap";
|
||||||
|
|
||||||
export let report: Report;
|
export let report: Report;
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { APIError } from "$lib/api/entities";
|
import type { APIError } from "$lib/api/entities";
|
||||||
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch";
|
import { apiFetchClient } from "$lib/api/fetch";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { APIError } from "$lib/api/entities";
|
import type { APIError } from "$lib/api/entities";
|
||||||
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch";
|
import { fastFetchClient } from "$lib/api/fetch";
|
||||||
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
||||||
import { addToast } from "$lib/toast";
|
import { addToast } from "$lib/toast";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -15,6 +15,7 @@ require (
|
||||||
github.com/go-chi/render v1.0.2
|
github.com/go-chi/render v1.0.2
|
||||||
github.com/gobwas/glob v0.2.3
|
github.com/gobwas/glob v0.2.3
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||||
|
github.com/google/uuid v1.3.0
|
||||||
github.com/jackc/pgx/v5 v5.3.1
|
github.com/jackc/pgx/v5 v5.3.1
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mediocregopher/radix/v4 v4.1.2
|
github.com/mediocregopher/radix/v4 v4.1.2
|
||||||
|
@ -40,7 +41,6 @@ require (
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
|
||||||
github.com/golang/protobuf v1.5.3 // indirect
|
github.com/golang/protobuf v1.5.3 // indirect
|
||||||
github.com/google/s2a-go v0.1.0 // indirect
|
github.com/google/s2a-go v0.1.0 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
|
||||||
github.com/gorilla/websocket v1.5.0 // indirect
|
github.com/gorilla/websocket v1.5.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
|
5
scripts/migrate/015_custom_preferences.sql
Normal file
5
scripts/migrate/015_custom_preferences.sql
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
-- +migrate Up
|
||||||
|
|
||||||
|
-- 2023-04-19: Add custom preferences
|
||||||
|
|
||||||
|
alter table users add column custom_preferences jsonb not null default '{}';
|
|
@ -48,7 +48,7 @@ func run(c *cli.Context) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = pg.UpdateUser(ctx, tx, u.ID, ptr("testing"), ptr("This is a bio!"), nil, ptr(false), &[]string{"https://pronouns.cc"}, nil)
|
_, err = pg.UpdateUser(ctx, tx, u.ID, ptr("testing"), ptr("This is a bio!"), nil, ptr(false), &[]string{"https://pronouns.cc"}, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("error setting user info:", err)
|
fmt.Println("error setting user info:", err)
|
||||||
return err
|
return err
|
||||||
|
|
Loading…
Reference in a new issue