merge branch 'feature/flags'
This commit is contained in:
commit
e993d2a89e
29 changed files with 1322 additions and 73 deletions
|
@ -19,6 +19,7 @@ import (
|
|||
|
||||
const ErrInvalidDataURI = errors.Sentinel("invalid data URI")
|
||||
const ErrInvalidContentType = errors.Sentinel("invalid avatar content type")
|
||||
const ErrFileTooLarge = errors.Sentinel("file to be converted exceeds maximum size")
|
||||
|
||||
// ConvertAvatar parses an avatar from a data URI, converts it to WebP and JPEG, and returns the results.
|
||||
func (db *DB) ConvertAvatar(data string) (
|
||||
|
|
|
@ -22,6 +22,11 @@ var sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
|
|||
|
||||
const ErrNothingToUpdate = errors.Sentinel("nothing to update")
|
||||
|
||||
const (
|
||||
uniqueViolation = "23505"
|
||||
foreignKeyViolation = "23503"
|
||||
)
|
||||
|
||||
type Execer interface {
|
||||
Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error)
|
||||
}
|
||||
|
|
322
backend/db/flags.go
Normal file
322
backend/db/flags.go
Normal file
|
@ -0,0 +1,322 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||
"emperror.dev/errors"
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
"github.com/georgysavva/scany/v2/pgxscan"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
type PrideFlag struct {
|
||||
ID xid.ID `json:"id"`
|
||||
UserID xid.ID `json:"-"`
|
||||
Hash string `json:"hash"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
type UserFlag struct {
|
||||
ID int64 `json:"-"`
|
||||
UserID xid.ID `json:"-"`
|
||||
FlagID xid.ID `json:"id"`
|
||||
|
||||
Hash string `json:"hash"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
type MemberFlag struct {
|
||||
ID int64 `json:"-"`
|
||||
MemberID xid.ID `json:"-"`
|
||||
FlagID xid.ID `json:"id"`
|
||||
|
||||
Hash string `json:"hash"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
const (
|
||||
MaxPrideFlags = 100
|
||||
MaxPrideFlagTitleLength = 100
|
||||
MaxPrideFlagDescLength = 500
|
||||
)
|
||||
|
||||
const (
|
||||
ErrInvalidFlagID = errors.Sentinel("invalid flag ID")
|
||||
ErrFlagNotFound = errors.Sentinel("flag not found")
|
||||
)
|
||||
|
||||
func (db *DB) AccountFlags(ctx context.Context, userID xid.ID) (fs []PrideFlag, err error) {
|
||||
sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("lower(name)").ToSql()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "building query")
|
||||
}
|
||||
|
||||
err = pgxscan.Select(ctx, db, &fs, sql, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "executing query")
|
||||
}
|
||||
return NotNull(fs), nil
|
||||
}
|
||||
|
||||
func (db *DB) UserFlag(ctx context.Context, flagID xid.ID) (f PrideFlag, err error) {
|
||||
sql, args, err := sq.Select("*").From("pride_flags").Where("id = ?", flagID).ToSql()
|
||||
if err != nil {
|
||||
return f, errors.Wrap(err, "building query")
|
||||
}
|
||||
|
||||
err = pgxscan.Get(ctx, db, &f, sql, args...)
|
||||
if err != nil {
|
||||
if errors.Cause(err) == pgx.ErrNoRows {
|
||||
return f, ErrFlagNotFound
|
||||
}
|
||||
|
||||
return f, errors.Wrap(err, "executing query")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (db *DB) UserFlags(ctx context.Context, userID xid.ID) (fs []UserFlag, err error) {
|
||||
sql, args, err := sq.Select("u.id", "u.flag_id", "f.user_id", "f.hash", "f.name", "f.description").
|
||||
From("user_flags AS u").
|
||||
Where("u.user_id = $1", userID).
|
||||
Join("pride_flags AS f ON u.flag_id = f.id").
|
||||
OrderBy("u.id ASC").
|
||||
ToSql()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "building query")
|
||||
}
|
||||
|
||||
err = pgxscan.Select(ctx, db, &fs, sql, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "executing query")
|
||||
}
|
||||
return NotNull(fs), nil
|
||||
}
|
||||
|
||||
func (db *DB) MemberFlags(ctx context.Context, memberID xid.ID) (fs []MemberFlag, err error) {
|
||||
sql, args, err := sq.Select("m.id", "m.flag_id", "m.member_id", "f.hash", "f.name", "f.description").
|
||||
From("member_flags AS m").
|
||||
Where("m.member_id = $1", memberID).
|
||||
Join("pride_flags AS f ON m.flag_id = f.id").
|
||||
OrderBy("m.id ASC").
|
||||
ToSql()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "building query")
|
||||
}
|
||||
|
||||
err = pgxscan.Select(ctx, db, &fs, sql, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "executing query")
|
||||
}
|
||||
return NotNull(fs), nil
|
||||
}
|
||||
|
||||
func (db *DB) SetUserFlags(ctx context.Context, tx pgx.Tx, userID xid.ID, flags []xid.ID) (err error) {
|
||||
sql, args, err := sq.Delete("user_flags").Where("user_id = ?", userID).ToSql()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "building sql")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "deleting existing flags")
|
||||
}
|
||||
|
||||
n, err := tx.CopyFrom(ctx, pgx.Identifier{"user_flags"}, []string{"user_id", "flag_id"},
|
||||
pgx.CopyFromSlice(len(flags), func(i int) ([]any, error) {
|
||||
return []any{userID, flags[i]}, nil
|
||||
}))
|
||||
if err != nil {
|
||||
pge := &pgconn.PgError{}
|
||||
if errors.As(err, &pge) {
|
||||
if pge.Code == foreignKeyViolation {
|
||||
return ErrInvalidFlagID
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Wrap(err, "copying new flags")
|
||||
}
|
||||
if n > 0 {
|
||||
log.Debugf("set %v flags for user %v", n, userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) SetMemberFlags(ctx context.Context, tx pgx.Tx, memberID xid.ID, flags []xid.ID) (err error) {
|
||||
sql, args, err := sq.Delete("member_flags").Where("member_id = ?", memberID).ToSql()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "building sql")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "deleting existing flags")
|
||||
}
|
||||
|
||||
n, err := tx.CopyFrom(ctx, pgx.Identifier{"member_flags"}, []string{"member_id", "flag_id"},
|
||||
pgx.CopyFromSlice(len(flags), func(i int) ([]any, error) {
|
||||
return []any{memberID, flags[i]}, nil
|
||||
}))
|
||||
if err != nil {
|
||||
pge := &pgconn.PgError{}
|
||||
if errors.As(err, &pge) {
|
||||
if pge.Code == foreignKeyViolation {
|
||||
return ErrInvalidFlagID
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Wrap(err, "copying new flags")
|
||||
}
|
||||
if n > 0 {
|
||||
log.Debugf("set %v flags for member %v", n, memberID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateFlag(ctx context.Context, tx pgx.Tx, userID xid.ID, name, desc string) (f PrideFlag, err error) {
|
||||
description := &desc
|
||||
if desc == "" {
|
||||
description = nil
|
||||
}
|
||||
|
||||
sql, args, err := sq.Insert("pride_flags").
|
||||
SetMap(map[string]any{
|
||||
"id": xid.New(),
|
||||
"hash": "",
|
||||
"user_id": userID.String(),
|
||||
"name": name,
|
||||
"description": description,
|
||||
}).Suffix("RETURNING *").ToSql()
|
||||
if err != nil {
|
||||
return f, errors.Wrap(err, "building query")
|
||||
}
|
||||
|
||||
err = pgxscan.Get(ctx, tx, &f, sql, args...)
|
||||
if err != nil {
|
||||
return f, errors.Wrap(err, "executing query")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (db *DB) EditFlag(ctx context.Context, tx pgx.Tx, flagID xid.ID, name, desc, hash *string) (f PrideFlag, err error) {
|
||||
b := sq.Update("pride_flags").
|
||||
Where("id = ?", flagID)
|
||||
if name != nil {
|
||||
b = b.Set("name", *name)
|
||||
}
|
||||
if desc != nil {
|
||||
if *desc == "" {
|
||||
b = b.Set("description", nil)
|
||||
} else {
|
||||
b = b.Set("description", *desc)
|
||||
}
|
||||
}
|
||||
if hash != nil {
|
||||
b = b.Set("hash", *hash)
|
||||
}
|
||||
|
||||
sql, args, err := b.Suffix("RETURNING *").ToSql()
|
||||
if err != nil {
|
||||
return f, errors.Wrap(err, "building sql")
|
||||
}
|
||||
|
||||
err = pgxscan.Get(ctx, tx, &f, sql, args...)
|
||||
if err != nil {
|
||||
return f, errors.Wrap(err, "executing query")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (db *DB) WriteFlag(ctx context.Context, flagID xid.ID, flag *bytes.Buffer) (hash string, err error) {
|
||||
hasher := sha256.New()
|
||||
_, err = hasher.Write(flag.Bytes())
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "hashing flag")
|
||||
}
|
||||
hash = hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
_, err = db.minio.PutObject(ctx, db.minioBucket, "/flags/"+hash+".webp", flag, -1, minio.PutObjectOptions{
|
||||
ContentType: "image/webp",
|
||||
})
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "uploading flag")
|
||||
}
|
||||
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteFlag(ctx context.Context, flagID xid.ID, hash string) error {
|
||||
sql, args, err := sq.Delete("pride_flags").Where("id = ?", flagID).ToSql()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "building sql")
|
||||
}
|
||||
|
||||
_, err = db.Exec(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "executing query")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) FlagObject(ctx context.Context, flagID xid.ID, hash string) (io.ReadCloser, error) {
|
||||
obj, err := db.minio.GetObject(ctx, db.minioBucket, "/flags/"+flagID.String()+"/"+hash+".webp", minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getting object")
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
const MaxFlagInputSize = 512_000
|
||||
|
||||
// ConvertFlag parses a flag from a data URI, converts it to WebP, and returns the result.
|
||||
func (db *DB) ConvertFlag(data string) (webpOut *bytes.Buffer, err error) {
|
||||
defer vips.ShutdownThread()
|
||||
|
||||
data = strings.TrimSpace(data)
|
||||
if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") {
|
||||
return nil, ErrInvalidDataURI
|
||||
}
|
||||
split := strings.Split(data, ",")
|
||||
|
||||
rawData, err := base64.StdEncoding.DecodeString(split[1])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "invalid base64 data")
|
||||
}
|
||||
|
||||
if len(rawData) > MaxFlagInputSize {
|
||||
return nil, ErrFileTooLarge
|
||||
}
|
||||
|
||||
image, err := vips.LoadImageFromBuffer(rawData, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "decoding image")
|
||||
}
|
||||
|
||||
err = image.ThumbnailWithSize(256, 256, vips.InterestingNone, vips.SizeBoth)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "resizing image")
|
||||
}
|
||||
|
||||
webpExport := vips.NewWebpExportParams()
|
||||
webpExport.Lossless = true
|
||||
webpB, _, err := image.ExportWebp(webpExport)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "exporting webp image")
|
||||
}
|
||||
webpOut = bytes.NewBuffer(webpB)
|
||||
|
||||
return webpOut, nil
|
||||
}
|
|
@ -116,7 +116,7 @@ func (db *DB) CreateMember(
|
|||
pge := &pgconn.PgError{}
|
||||
if errors.As(err, &pge) {
|
||||
// unique constraint violation
|
||||
if pge.Code == "23505" {
|
||||
if pge.Code == uniqueViolation {
|
||||
return m, ErrMemberNameInUse
|
||||
}
|
||||
}
|
||||
|
@ -223,7 +223,7 @@ func (db *DB) UpdateMember(
|
|||
if err != nil {
|
||||
pge := &pgconn.PgError{}
|
||||
if errors.As(err, &pge) {
|
||||
if pge.Code == "23505" {
|
||||
if pge.Code == uniqueViolation {
|
||||
return m, ErrMemberNameInUse
|
||||
}
|
||||
}
|
||||
|
|
|
@ -171,7 +171,7 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use
|
|||
pge := &pgconn.PgError{}
|
||||
if errors.As(err, &pge) {
|
||||
// unique constraint violation
|
||||
if pge.Code == "23505" {
|
||||
if pge.Code == uniqueViolation {
|
||||
return u, ErrUsernameTaken
|
||||
}
|
||||
}
|
||||
|
@ -494,7 +494,7 @@ func (db *DB) UpdateUsername(ctx context.Context, tx pgx.Tx, id xid.ID, newName
|
|||
pge := &pgconn.PgError{}
|
||||
if errors.As(err, &pge) {
|
||||
// unique constraint violation
|
||||
if pge.Code == "23505" {
|
||||
if pge.Code == uniqueViolation {
|
||||
return ErrUsernameTaken
|
||||
}
|
||||
}
|
||||
|
|
|
@ -188,7 +188,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
|
|||
return errors.Wrap(err, "committing transaction")
|
||||
}
|
||||
|
||||
render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields, true))
|
||||
render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields, nil, true))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -23,13 +23,14 @@ type GetMemberResponse struct {
|
|||
Names []db.FieldEntry `json:"names"`
|
||||
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||
Fields []db.Field `json:"fields"`
|
||||
Flags []db.MemberFlag `json:"flags"`
|
||||
|
||||
User PartialUser `json:"user"`
|
||||
|
||||
Unlisted *bool `json:"unlisted,omitempty"`
|
||||
}
|
||||
|
||||
func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember bool) GetMemberResponse {
|
||||
func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.MemberFlag, isOwnMember bool) GetMemberResponse {
|
||||
r := GetMemberResponse{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
|
@ -41,6 +42,7 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember boo
|
|||
Names: db.NotNull(m.Names),
|
||||
Pronouns: db.NotNull(m.Pronouns),
|
||||
Fields: db.NotNull(fields),
|
||||
Flags: flags,
|
||||
|
||||
User: PartialUser{
|
||||
ID: u.ID,
|
||||
|
@ -102,7 +104,12 @@ func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {
|
|||
return err
|
||||
}
|
||||
|
||||
render.JSON(w, r, dbMemberToMember(u, m, fields, isOwnMember))
|
||||
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -137,7 +144,12 @@ func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error {
|
|||
return err
|
||||
}
|
||||
|
||||
render.JSON(w, r, dbMemberToMember(u, m, fields, isOwnMember))
|
||||
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ type PatchMemberRequest struct {
|
|||
Fields *[]db.Field `json:"fields"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Unlisted *bool `json:"unlisted"`
|
||||
Flags *[]xid.ID `json:"flags"`
|
||||
}
|
||||
|
||||
func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||
|
@ -74,7 +75,8 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
|||
req.Fields == nil &&
|
||||
req.Names == nil &&
|
||||
req.Pronouns == nil &&
|
||||
req.Avatar == nil {
|
||||
req.Avatar == nil &&
|
||||
req.Flags == nil {
|
||||
return server.APIError{
|
||||
Code: server.ErrBadRequest,
|
||||
Details: "Data must not be empty",
|
||||
|
@ -153,6 +155,16 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
|||
}
|
||||
}
|
||||
|
||||
// validate flag length
|
||||
if req.Flags != nil {
|
||||
if len(*req.Flags) > db.MaxPrideFlags {
|
||||
return server.APIError{
|
||||
Code: server.ErrBadRequest,
|
||||
Details: fmt.Sprintf("Too many flags (max %d, current %d)", len(*req.Flags), db.MaxPrideFlags),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil {
|
||||
return *err
|
||||
}
|
||||
|
@ -270,6 +282,19 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
|||
}
|
||||
}
|
||||
|
||||
// update flags
|
||||
if req.Flags != nil {
|
||||
err = s.DB.SetMemberFlags(ctx, tx, m.ID, *req.Flags)
|
||||
if err != nil {
|
||||
if err == db.ErrInvalidFlagID {
|
||||
return server.APIError{Code: server.ErrBadRequest, Details: "One or more flag IDs are unknown"}
|
||||
}
|
||||
|
||||
log.Errorf("updating flags for member %v: %v", m.ID, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// update last active time
|
||||
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
|
||||
if err != nil {
|
||||
|
@ -283,7 +308,14 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// get flags to return (we need to return full flag objects, not the array of IDs in the request body)
|
||||
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
||||
if err != nil {
|
||||
log.Errorf("getting user flags: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// echo the updated member back on success
|
||||
render.JSON(w, r, dbMemberToMember(u, m, fields, true))
|
||||
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))
|
||||
return nil
|
||||
}
|
||||
|
|
239
backend/routes/user/flags.go
Normal file
239
backend/routes/user/flags.go
Normal file
|
@ -0,0 +1,239 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/u1f320/pronouns.cc/backend/common"
|
||||
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||
"emperror.dev/errors"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
func (s *Server) getUserFlags(w http.ResponseWriter, r *http.Request) error {
|
||||
ctx := r.Context()
|
||||
claims, _ := server.ClaimsFromContext(ctx)
|
||||
|
||||
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "getting flags for account %v", claims.UserID)
|
||||
}
|
||||
|
||||
render.JSON(w, r, flags)
|
||||
return nil
|
||||
}
|
||||
|
||||
type postUserFlagRequest struct {
|
||||
Flag string `json:"flag"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error {
|
||||
ctx := r.Context()
|
||||
claims, _ := server.ClaimsFromContext(ctx)
|
||||
|
||||
if !claims.TokenWrite {
|
||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
|
||||
}
|
||||
|
||||
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting current user flags")
|
||||
}
|
||||
if len(flags) >= db.MaxPrideFlags {
|
||||
return server.APIError{
|
||||
Code: server.ErrFlagLimitReached,
|
||||
}
|
||||
}
|
||||
|
||||
var req postUserFlagRequest
|
||||
err = render.Decode(r, &req)
|
||||
if err != nil {
|
||||
return server.APIError{Code: server.ErrBadRequest}
|
||||
}
|
||||
|
||||
// remove whitespace from all fields
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Description = strings.TrimSpace(req.Description)
|
||||
|
||||
if s := common.StringLength(&req.Name); s > db.MaxPrideFlagTitleLength {
|
||||
return server.APIError{
|
||||
Code: server.ErrBadRequest,
|
||||
Details: fmt.Sprintf("name too long, must be %v characters or less, is %v", db.MaxPrideFlagTitleLength, s),
|
||||
}
|
||||
}
|
||||
if s := common.StringLength(&req.Description); s > db.MaxPrideFlagDescLength {
|
||||
return server.APIError{
|
||||
Code: server.ErrBadRequest,
|
||||
Details: fmt.Sprintf("description too long, must be %v characters or less, is %v", db.MaxPrideFlagDescLength, s),
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "starting transaction")
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
flag, err := s.DB.CreateFlag(ctx, tx, claims.UserID, req.Name, req.Description)
|
||||
if err != nil {
|
||||
log.Errorf("creating flag: %v", err)
|
||||
return errors.Wrap(err, "creating flag")
|
||||
}
|
||||
|
||||
webp, err := s.DB.ConvertFlag(req.Flag)
|
||||
if err != nil {
|
||||
if err == db.ErrInvalidDataURI {
|
||||
return server.APIError{Code: server.ErrBadRequest, Message: "invalid data URI"}
|
||||
} else if err == db.ErrFileTooLarge {
|
||||
return server.APIError{Code: server.ErrBadRequest, Message: "data URI exceeds 512 KB"}
|
||||
}
|
||||
return errors.Wrap(err, "converting flag")
|
||||
}
|
||||
|
||||
hash, err := s.DB.WriteFlag(ctx, flag.ID, webp)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "writing flag")
|
||||
}
|
||||
|
||||
flag, err = s.DB.EditFlag(ctx, tx, flag.ID, nil, nil, &hash)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "setting hash for flag")
|
||||
}
|
||||
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "committing transaction")
|
||||
}
|
||||
|
||||
render.JSON(w, r, flag)
|
||||
return nil
|
||||
}
|
||||
|
||||
type patchUserFlagRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error {
|
||||
ctx := r.Context()
|
||||
claims, _ := server.ClaimsFromContext(ctx)
|
||||
|
||||
if !claims.TokenWrite {
|
||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
|
||||
}
|
||||
|
||||
flagID, err := xid.FromString(chi.URLParam(r, "flagID"))
|
||||
if err != nil {
|
||||
return server.APIError{Code: server.ErrNotFound, Details: "Invalid flag ID"}
|
||||
}
|
||||
|
||||
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting current user flags")
|
||||
}
|
||||
if len(flags) >= db.MaxPrideFlags {
|
||||
return server.APIError{
|
||||
Code: server.ErrFlagLimitReached,
|
||||
}
|
||||
}
|
||||
var found bool
|
||||
for _, flag := range flags {
|
||||
if flag.ID == flagID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"}
|
||||
}
|
||||
|
||||
var req patchUserFlagRequest
|
||||
err = render.Decode(r, &req)
|
||||
if err != nil {
|
||||
return server.APIError{Code: server.ErrBadRequest}
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
*req.Name = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if req.Description != nil {
|
||||
*req.Description = strings.TrimSpace(*req.Description)
|
||||
}
|
||||
|
||||
if req.Name == nil && req.Description == nil {
|
||||
return server.APIError{Code: server.ErrBadRequest, Details: "Request cannot be empty"}
|
||||
}
|
||||
|
||||
if s := common.StringLength(req.Name); s > db.MaxPrideFlagTitleLength {
|
||||
return server.APIError{
|
||||
Code: server.ErrBadRequest,
|
||||
Details: fmt.Sprintf("name too long, must be %v characters or less, is %v", db.MaxPrideFlagTitleLength, s),
|
||||
}
|
||||
}
|
||||
if s := common.StringLength(req.Description); s > db.MaxPrideFlagDescLength {
|
||||
return server.APIError{
|
||||
Code: server.ErrBadRequest,
|
||||
Details: fmt.Sprintf("description too long, must be %v characters or less, is %v", db.MaxPrideFlagDescLength, s),
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "beginning transaction")
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
flag, err := s.DB.EditFlag(ctx, tx, flagID, req.Name, req.Description, nil)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "updating flag")
|
||||
}
|
||||
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "committing transaction")
|
||||
}
|
||||
|
||||
render.JSON(w, r, flag)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) deleteUserFlag(w http.ResponseWriter, r *http.Request) error {
|
||||
ctx := r.Context()
|
||||
claims, _ := server.ClaimsFromContext(ctx)
|
||||
|
||||
if !claims.TokenWrite {
|
||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
|
||||
}
|
||||
|
||||
flagID, err := xid.FromString(chi.URLParam(r, "flagID"))
|
||||
if err != nil {
|
||||
return server.APIError{Code: server.ErrNotFound, Details: "Invalid flag ID"}
|
||||
}
|
||||
|
||||
flag, err := s.DB.UserFlag(ctx, flagID)
|
||||
if err != nil {
|
||||
if err == db.ErrFlagNotFound {
|
||||
return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"}
|
||||
}
|
||||
|
||||
return errors.Wrap(err, "getting flag object")
|
||||
}
|
||||
if flag.UserID != claims.UserID {
|
||||
return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"}
|
||||
}
|
||||
|
||||
err = s.DB.DeleteFlag(ctx, flag.ID, flag.Hash)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "deleting flag")
|
||||
}
|
||||
|
||||
render.NoContent(w, r)
|
||||
return nil
|
||||
}
|
|
@ -25,6 +25,7 @@ type GetUserResponse struct {
|
|||
Members []PartialMember `json:"members"`
|
||||
Fields []db.Field `json:"fields"`
|
||||
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
|
||||
Flags []db.UserFlag `json:"flags"`
|
||||
}
|
||||
|
||||
type GetMeResponse struct {
|
||||
|
@ -61,7 +62,7 @@ type PartialMember struct {
|
|||
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||
}
|
||||
|
||||
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUserResponse {
|
||||
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags []db.UserFlag) GetUserResponse {
|
||||
resp := GetUserResponse{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
|
@ -74,6 +75,7 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUser
|
|||
Pronouns: db.NotNull(u.Pronouns),
|
||||
Fields: db.NotNull(fields),
|
||||
CustomPreferences: u.CustomPreferences,
|
||||
Flags: flags,
|
||||
}
|
||||
|
||||
resp.Members = make([]PartialMember, len(members))
|
||||
|
@ -93,56 +95,29 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUser
|
|||
return resp
|
||||
}
|
||||
|
||||
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error {
|
||||
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
|
||||
ctx := r.Context()
|
||||
|
||||
userRef := chi.URLParamFromCtx(ctx, "userRef")
|
||||
|
||||
var u db.User
|
||||
if id, err := xid.FromString(userRef); err == nil {
|
||||
u, err := s.DB.User(ctx, id)
|
||||
if err == nil {
|
||||
if u.DeletedAt != nil {
|
||||
return server.APIError{Code: server.ErrUserNotFound}
|
||||
}
|
||||
|
||||
isSelf := false
|
||||
if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID {
|
||||
isSelf = true
|
||||
}
|
||||
|
||||
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||
if err != nil {
|
||||
log.Errorf("Error getting user fields: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var members []db.Member
|
||||
if !u.ListPrivate || isSelf {
|
||||
members, err = s.DB.UserMembers(ctx, u.ID, isSelf)
|
||||
if err != nil {
|
||||
log.Errorf("Error getting user members: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
render.JSON(w, r, dbUserToResponse(u, fields, members))
|
||||
return nil
|
||||
} else if err != db.ErrUserNotFound {
|
||||
log.Errorf("Error getting user by ID: %v", err)
|
||||
return err
|
||||
u, err = s.DB.User(ctx, id)
|
||||
if err != nil {
|
||||
log.Errorf("getting user by ID: %v", err)
|
||||
}
|
||||
// otherwise, we fall back to checking usernames
|
||||
}
|
||||
|
||||
u, err := s.DB.Username(ctx, userRef)
|
||||
if err == db.ErrUserNotFound {
|
||||
return server.APIError{
|
||||
Code: server.ErrUserNotFound,
|
||||
if u.ID.IsNil() {
|
||||
u, err = s.DB.Username(ctx, userRef)
|
||||
if err == db.ErrUserNotFound {
|
||||
return server.APIError{
|
||||
Code: server.ErrUserNotFound,
|
||||
}
|
||||
} else if err != nil {
|
||||
log.Errorf("Error getting user by username: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
} else if err != nil {
|
||||
log.Errorf("Error getting user by username: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if u.DeletedAt != nil {
|
||||
|
@ -160,6 +135,12 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error {
|
|||
return err
|
||||
}
|
||||
|
||||
flags, err := s.DB.UserFlags(ctx, u.ID)
|
||||
if err != nil {
|
||||
log.Errorf("getting user flags: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var members []db.Member
|
||||
if !u.ListPrivate || isSelf {
|
||||
members, err = s.DB.UserMembers(ctx, u.ID, isSelf)
|
||||
|
@ -169,7 +150,7 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error {
|
|||
}
|
||||
}
|
||||
|
||||
render.JSON(w, r, dbUserToResponse(u, fields, members))
|
||||
render.JSON(w, r, dbUserToResponse(u, fields, members, flags))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -195,8 +176,14 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
|
|||
return err
|
||||
}
|
||||
|
||||
flags, err := s.DB.UserFlags(ctx, u.ID)
|
||||
if err != nil {
|
||||
log.Errorf("getting user flags: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
render.JSON(w, r, GetMeResponse{
|
||||
GetUserResponse: dbUserToResponse(u, fields, members),
|
||||
GetUserResponse: dbUserToResponse(u, fields, members, flags),
|
||||
CreatedAt: u.ID.Time(),
|
||||
MaxInvites: u.MaxInvites,
|
||||
IsAdmin: u.IsAdmin,
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"emperror.dev/errors"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
type PatchUserRequest struct {
|
||||
|
@ -25,6 +26,7 @@ type PatchUserRequest struct {
|
|||
Avatar *string `json:"avatar"`
|
||||
ListPrivate *bool `json:"list_private"`
|
||||
CustomPreferences *db.CustomPreferences `json:"custom_preferences"`
|
||||
Flags *[]xid.ID `json:"flags"`
|
||||
}
|
||||
|
||||
// patchUser parses a PatchUserRequest and updates the user with the given ID.
|
||||
|
@ -60,7 +62,8 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
|||
req.Names == nil &&
|
||||
req.Pronouns == nil &&
|
||||
req.Avatar == nil &&
|
||||
req.CustomPreferences == nil {
|
||||
req.CustomPreferences == nil &&
|
||||
req.Flags == nil {
|
||||
return server.APIError{
|
||||
Code: server.ErrBadRequest,
|
||||
Details: "Data must not be empty",
|
||||
|
@ -106,6 +109,16 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
|||
}
|
||||
}
|
||||
|
||||
// validate flag length
|
||||
if req.Flags != nil {
|
||||
if len(*req.Flags) > db.MaxPrideFlags {
|
||||
return server.APIError{
|
||||
Code: server.ErrBadRequest,
|
||||
Details: fmt.Sprintf("Too many flags (max %d, current %d)", len(*req.Flags), db.MaxPrideFlags),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validate custom preferences
|
||||
if req.CustomPreferences != nil {
|
||||
if count := len(*req.CustomPreferences); count > db.MaxFields {
|
||||
|
@ -252,6 +265,19 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
|||
}
|
||||
}
|
||||
|
||||
// update flags
|
||||
if req.Flags != nil {
|
||||
err = s.DB.SetUserFlags(ctx, tx, claims.UserID, *req.Flags)
|
||||
if err != nil {
|
||||
if err == db.ErrInvalidFlagID {
|
||||
return server.APIError{Code: server.ErrBadRequest, Details: "One or more flag IDs are unknown"}
|
||||
}
|
||||
|
||||
log.Errorf("updating flags for user %v: %v", claims.UserID, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// update last active time
|
||||
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
|
||||
if err != nil {
|
||||
|
@ -274,9 +300,16 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
|||
}
|
||||
}
|
||||
|
||||
// get flags to return (we need to return full flag objects, not the array of IDs in the request body)
|
||||
flags, err := s.DB.UserFlags(ctx, u.ID)
|
||||
if err != nil {
|
||||
log.Errorf("getting user flags: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// echo the updated user back on success
|
||||
render.JSON(w, r, GetMeResponse{
|
||||
GetUserResponse: dbUserToResponse(u, fields, nil),
|
||||
GetUserResponse: dbUserToResponse(u, fields, nil, flags),
|
||||
MaxInvites: u.MaxInvites,
|
||||
IsAdmin: u.IsAdmin,
|
||||
ListPrivate: u.ListPrivate,
|
||||
|
|
|
@ -29,6 +29,11 @@ func Mount(srv *server.Server, r chi.Router) {
|
|||
|
||||
r.Get("/@me/export/start", server.WrapHandler(s.startExport))
|
||||
r.Get("/@me/export", server.WrapHandler(s.getExport))
|
||||
|
||||
r.Get("/@me/flags", server.WrapHandler(s.getUserFlags))
|
||||
r.Post("/@me/flags", server.WrapHandler(s.postUserFlag))
|
||||
r.Patch("/@me/flags/{flagID}", server.WrapHandler(s.patchUserFlag))
|
||||
r.Delete("/@me/flags/{flagID}", server.WrapHandler(s.deleteUserFlag))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -102,6 +102,7 @@ const (
|
|||
// User-related error codes
|
||||
ErrUserNotFound = 2001
|
||||
ErrMemberListPrivate = 2002
|
||||
ErrFlagLimitReached = 2003
|
||||
|
||||
// Member-related error codes
|
||||
ErrMemberNotFound = 3001
|
||||
|
@ -145,7 +146,8 @@ var errCodeMessages = map[int]string{
|
|||
ErrInvalidCaptcha: "Invalid or missing captcha response",
|
||||
|
||||
ErrUserNotFound: "User not found",
|
||||
ErrMemberListPrivate: "This user's member list is private.",
|
||||
ErrMemberListPrivate: "This user's member list is private",
|
||||
ErrFlagLimitReached: "Maximum number of pride flags reached",
|
||||
|
||||
ErrMemberNotFound: "Member not found",
|
||||
ErrMemberLimitReached: "Member limit reached",
|
||||
|
@ -187,6 +189,7 @@ var errCodeStatuses = map[int]int{
|
|||
|
||||
ErrUserNotFound: http.StatusNotFound,
|
||||
ErrMemberListPrivate: http.StatusForbidden,
|
||||
ErrFlagLimitReached: http.StatusBadRequest,
|
||||
|
||||
ErrMemberNotFound: http.StatusNotFound,
|
||||
ErrMemberLimitReached: http.StatusBadRequest,
|
||||
|
|
|
@ -17,6 +17,7 @@ export interface User {
|
|||
pronouns: Pronoun[];
|
||||
members: PartialMember[];
|
||||
fields: Field[];
|
||||
flags: PrideFlag[];
|
||||
custom_preferences: CustomPreferences;
|
||||
}
|
||||
|
||||
|
@ -83,6 +84,7 @@ export interface PartialMember {
|
|||
|
||||
export interface Member extends PartialMember {
|
||||
fields: Field[];
|
||||
flags: PrideFlag[];
|
||||
|
||||
user: MemberPartialUser;
|
||||
unlisted?: boolean;
|
||||
|
@ -96,6 +98,13 @@ export interface MemberPartialUser {
|
|||
custom_preferences: CustomPreferences;
|
||||
}
|
||||
|
||||
export interface PrideFlag {
|
||||
id: string;
|
||||
hash: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
code: string;
|
||||
created: string;
|
||||
|
@ -192,6 +201,8 @@ export const memberAvatars = (member: Member | PartialMember) => {
|
|||
];
|
||||
};
|
||||
|
||||
export const flagURL = ({ hash }: PrideFlag) => `${PUBLIC_MEDIA_URL}/flags/${hash}.webp`;
|
||||
|
||||
export const defaultAvatars = [
|
||||
`${PUBLIC_BASE_URL}/default/512.webp`,
|
||||
`${PUBLIC_BASE_URL}/default/512.jpg`,
|
||||
|
|
|
@ -40,6 +40,7 @@
|
|||
import StatusLine from "$lib/components/StatusLine.svelte";
|
||||
import defaultPreferences from "$lib/api/default_preferences";
|
||||
import { addToast } from "$lib/toast";
|
||||
import ProfileFlag from "./ProfileFlag.svelte";
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
|
@ -117,16 +118,16 @@
|
|||
|
||||
onMount(async () => {
|
||||
if ($userStore && $userStore.id === data.id) {
|
||||
console.log("User is current user, fetching members")
|
||||
console.log("User is current user, fetching members");
|
||||
try {
|
||||
const members = await apiFetchClient<PartialMember[]>("/users/@me/members");
|
||||
data.members = members;
|
||||
} catch (e) {
|
||||
// If it fails, we fail silently but log to console anyway
|
||||
console.error("Fetching members:", e)
|
||||
console.error("Fetching members:", e);
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
|
@ -140,6 +141,13 @@
|
|||
<div class="row">
|
||||
<div class="col-md-4 text-center">
|
||||
<FallbackImage width={200} urls={userAvatars(data)} alt="Avatar for @{data.name}" />
|
||||
{#if data.flags && data.bio}
|
||||
<div class="d-flex flex-wrap m-4">
|
||||
{#each data.flags as flag}
|
||||
<ProfileFlag {flag} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="col-md">
|
||||
{#if data.display_name}
|
||||
|
@ -174,6 +182,13 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if data.flags && !data.bio}
|
||||
<div class="d-flex flex-wrap m-4">
|
||||
{#each data.flags as flag}
|
||||
<ProfileFlag {flag} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
|
||||
{#if data.names.length > 0}
|
||||
<div class="col-md">
|
||||
|
|
22
frontend/src/routes/@[username]/ProfileFlag.svelte
Normal file
22
frontend/src/routes/@[username]/ProfileFlag.svelte
Normal file
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
import { flagURL, type PrideFlag } from "$lib/api/entities";
|
||||
import { Tooltip } from "sveltestrap";
|
||||
|
||||
export let flag: PrideFlag;
|
||||
|
||||
let elem: HTMLElement;
|
||||
</script>
|
||||
|
||||
<span class="mx-2 my-1">
|
||||
<Tooltip target={elem} aria-hidden placement="top">{flag.description ?? flag.name}</Tooltip>
|
||||
<img bind:this={elem} class="flag" src={flagURL(flag)} alt={flag.description ?? flag.name} />
|
||||
{flag.name}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.flag {
|
||||
height: 1.5rem;
|
||||
max-width: 200px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
|
@ -20,6 +20,7 @@
|
|||
import StatusLine from "$lib/components/StatusLine.svelte";
|
||||
import defaultPreferences from "$lib/api/default_preferences";
|
||||
import { addToast } from "$lib/toast";
|
||||
import ProfileFlag from "../ProfileFlag.svelte";
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
|
@ -69,6 +70,13 @@
|
|||
<div class="row">
|
||||
<div class="col-md-4 text-center">
|
||||
<FallbackImage width={200} urls={memberAvatars(data)} alt="Avatar for @{data.name}" />
|
||||
{#if data.flags && data.bio}
|
||||
<div class="d-flex flex-wrap m-4">
|
||||
{#each data.flags as flag}
|
||||
<ProfileFlag {flag} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<h2>{data.display_name ?? data.name}</h2>
|
||||
|
@ -97,6 +105,13 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if data.flags && !data.bio}
|
||||
<div class="d-flex flex-wrap m-4">
|
||||
{#each data.flags as flag}
|
||||
<ProfileFlag {flag} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
|
||||
{#if data.names.length > 0}
|
||||
<div class="col-md">
|
||||
|
|
26
frontend/src/routes/edit/FlagButton.svelte
Normal file
26
frontend/src/routes/edit/FlagButton.svelte
Normal file
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts">
|
||||
import { flagURL, type PrideFlag } from "$lib/api/entities";
|
||||
import { Button, Tooltip } from "sveltestrap";
|
||||
|
||||
export let flag: PrideFlag;
|
||||
export let tooltip: string;
|
||||
let className: string | null | undefined = undefined;
|
||||
export { className as class };
|
||||
|
||||
let elem: HTMLElement;
|
||||
</script>
|
||||
|
||||
<Tooltip target={elem} placement="top">{tooltip}</Tooltip>
|
||||
<Button bind:inner={elem} class={className} on:click color="secondary" outline>
|
||||
<img class="flag" src={flagURL(flag)} alt={flag.description ?? flag.name} />
|
||||
{flag.name}
|
||||
</Button>
|
||||
|
||||
<style>
|
||||
.flag {
|
||||
height: 1.5rem;
|
||||
max-width: 200px;
|
||||
border-radius: 3px;
|
||||
margin-left: -5px;
|
||||
}
|
||||
</style>
|
|
@ -8,6 +8,7 @@
|
|||
type FieldEntry,
|
||||
type Member,
|
||||
type Pronoun,
|
||||
type PrideFlag,
|
||||
} from "$lib/api/entities";
|
||||
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
||||
import {
|
||||
|
@ -40,6 +41,7 @@
|
|||
import { memberNameRegex } from "$lib/api/regex";
|
||||
import { charCount, renderMarkdown } from "$lib/utils";
|
||||
import MarkdownHelp from "../../MarkdownHelp.svelte";
|
||||
import FlagButton from "../../FlagButton.svelte";
|
||||
|
||||
const MAX_AVATAR_BYTES = 1_000_000;
|
||||
|
||||
|
@ -59,6 +61,7 @@
|
|||
let names: FieldEntry[] = window.structuredClone(data.member.names);
|
||||
let pronouns: Pronoun[] = window.structuredClone(data.member.pronouns);
|
||||
let fields: Field[] = window.structuredClone(data.member.fields);
|
||||
let flags: PrideFlag[] = window.structuredClone(data.member.flags);
|
||||
let unlisted: boolean = data.member.unlisted || false;
|
||||
|
||||
let memberNameValid = true;
|
||||
|
@ -71,6 +74,18 @@
|
|||
let newPronouns = "";
|
||||
let newLink = "";
|
||||
|
||||
let flagSearch = "";
|
||||
let filteredFlags: PrideFlag[];
|
||||
$: filteredFlags = filterFlags(flagSearch, data.flags);
|
||||
|
||||
const filterFlags = (search: string, flags: PrideFlag[]) => {
|
||||
return (
|
||||
search
|
||||
? flags.filter((flag) => flag.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()))
|
||||
: flags
|
||||
).slice(0, 25);
|
||||
};
|
||||
|
||||
let modified = false;
|
||||
|
||||
$: modified = isModified(
|
||||
|
@ -82,6 +97,7 @@
|
|||
names,
|
||||
pronouns,
|
||||
fields,
|
||||
flags,
|
||||
avatar,
|
||||
unlisted,
|
||||
);
|
||||
|
@ -96,6 +112,7 @@
|
|||
names: FieldEntry[],
|
||||
pronouns: Pronoun[],
|
||||
fields: Field[],
|
||||
flags: PrideFlag[],
|
||||
avatar: string | null,
|
||||
unlisted: boolean,
|
||||
) => {
|
||||
|
@ -104,6 +121,7 @@
|
|||
if (display_name !== member.display_name) return true;
|
||||
if (!linksEqual(links, member.links)) return true;
|
||||
if (!fieldsEqual(fields, member.fields)) return true;
|
||||
if (!flagsEqual(flags, member.flags)) return true;
|
||||
if (!namesEqual(names, member.names)) return true;
|
||||
if (!pronounsEqual(pronouns, member.pronouns)) return true;
|
||||
if (avatar !== null) return true;
|
||||
|
@ -147,6 +165,11 @@
|
|||
return arr1.every((_, i) => arr1[i] === arr2[i]);
|
||||
};
|
||||
|
||||
const flagsEqual = (arr1: PrideFlag[], arr2: PrideFlag[]) => {
|
||||
if (arr1.length !== arr2.length) return false;
|
||||
return arr1.every((_, i) => arr1[i].id === arr2[i].id);
|
||||
};
|
||||
|
||||
const getAvatar = async (list: FileList | null) => {
|
||||
if (!list || list.length === 0) return null;
|
||||
if (list[0].size > MAX_AVATAR_BYTES) {
|
||||
|
@ -211,6 +234,26 @@
|
|||
links[newIndex] = temp;
|
||||
};
|
||||
|
||||
const moveFlag = (index: number, up: boolean) => {
|
||||
if (up && index == 0) return;
|
||||
if (!up && index == flags.length - 1) return;
|
||||
|
||||
const newIndex = up ? index - 1 : index + 1;
|
||||
|
||||
const temp = flags[index];
|
||||
flags[index] = flags[newIndex];
|
||||
flags[newIndex] = temp;
|
||||
};
|
||||
|
||||
const addFlag = (flag: PrideFlag) => {
|
||||
flags = [...flags, flag];
|
||||
};
|
||||
|
||||
const removeFlag = (index: number) => {
|
||||
flags.splice(index, 1);
|
||||
flags = [...flags];
|
||||
};
|
||||
|
||||
const addName = (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
|
@ -281,6 +324,7 @@
|
|||
names,
|
||||
pronouns,
|
||||
fields,
|
||||
flags: flags.map((flag) => flag.id),
|
||||
unlisted,
|
||||
});
|
||||
|
||||
|
@ -541,6 +585,72 @@
|
|||
</Button>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane tabId="flags" tab="Flags">
|
||||
<div class="mt-3">
|
||||
{#each flags as _, index}
|
||||
<ButtonGroup class="m-1">
|
||||
<IconButton
|
||||
icon="chevron-left"
|
||||
color="secondary"
|
||||
tooltip="Move flag to the left"
|
||||
click={() => moveFlag(index, true)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-right"
|
||||
color="secondary"
|
||||
tooltip="Move flag to the right"
|
||||
click={() => moveFlag(index, false)}
|
||||
/>
|
||||
<FlagButton
|
||||
flag={flags[index]}
|
||||
tooltip="Remove this flag from your profile"
|
||||
on:click={() => removeFlag(index)}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
{/each}
|
||||
</div>
|
||||
<hr />
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<Input
|
||||
placeholder="Filter flags"
|
||||
bind:value={flagSearch}
|
||||
disabled={data.flags.length === 0}
|
||||
/>
|
||||
<div class="p-2">
|
||||
{#each filteredFlags as flag (flag.id)}
|
||||
<FlagButton
|
||||
{flag}
|
||||
tooltip="Add this flag to your profile"
|
||||
on:click={() => addFlag(flag)}
|
||||
/>
|
||||
{:else}
|
||||
{#if data.flags.length === 0}
|
||||
You haven't uploaded any flags yet.
|
||||
{:else}
|
||||
There are no flags matching your search <strong>{flagSearch}</strong>.
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<Alert color="secondary" fade={false}>
|
||||
{#if data.flags.length === 0}
|
||||
<p><strong>Why can't I see any flags?</strong></p>
|
||||
<p>
|
||||
There are thousands of pride flags, and it would be impossible to bundle all of them
|
||||
by default. Many labels also have multiple different flags that are favoured by
|
||||
different people. Because of this, there are no flags available by default--instead,
|
||||
you can upload flags in your <a href="/settings/flags">settings</a>. Your main profile
|
||||
and your member profiles can all have different flags.
|
||||
</p>
|
||||
{:else}
|
||||
To upload and delete flags, go to your <a href="/settings/flags">settings</a>.
|
||||
{/if}
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane tabId="links" tab="Links">
|
||||
<div class="mt-3">
|
||||
{#each links as _, index}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { MeUser, APIError, Member, PronounsJson } from "$lib/api/entities";
|
||||
import type { PrideFlag, MeUser, APIError, Member, PronounsJson } from "$lib/api/entities";
|
||||
import { apiFetchClient } from "$lib/api/fetch";
|
||||
import { error } from "@sveltejs/kit";
|
||||
|
||||
|
@ -11,11 +11,13 @@ export const load = async ({ params }) => {
|
|||
try {
|
||||
const user = await apiFetchClient<MeUser>(`/users/@me`);
|
||||
const member = await apiFetchClient<Member>(`/members/${params.id}`);
|
||||
const flags = await apiFetchClient<PrideFlag[]>("/users/@me/flags");
|
||||
|
||||
return {
|
||||
user,
|
||||
member,
|
||||
pronouns: pronouns.autocomplete,
|
||||
flags,
|
||||
};
|
||||
} catch (e) {
|
||||
throw error((e as APIError).code, (e as APIError).message);
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
type Pronoun,
|
||||
PreferenceSize,
|
||||
type CustomPreferences,
|
||||
type PrideFlag,
|
||||
} from "$lib/api/entities";
|
||||
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
||||
import { userStore } from "$lib/store";
|
||||
|
@ -39,6 +40,7 @@
|
|||
import MarkdownHelp from "../MarkdownHelp.svelte";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import CustomPreference from "./CustomPreference.svelte";
|
||||
import FlagButton from "../FlagButton.svelte";
|
||||
|
||||
const MAX_AVATAR_BYTES = 1_000_000;
|
||||
|
||||
|
@ -53,6 +55,7 @@
|
|||
let names: FieldEntry[] = window.structuredClone(data.user.names);
|
||||
let pronouns: Pronoun[] = window.structuredClone(data.user.pronouns);
|
||||
let fields: Field[] = window.structuredClone(data.user.fields);
|
||||
let flags: PrideFlag[] = window.structuredClone(data.user.flags);
|
||||
let list_private = data.user.list_private;
|
||||
let custom_preferences = window.structuredClone(data.user.custom_preferences);
|
||||
|
||||
|
@ -63,6 +66,18 @@
|
|||
let newPronouns = "";
|
||||
let newLink = "";
|
||||
|
||||
let flagSearch = "";
|
||||
let filteredFlags: PrideFlag[];
|
||||
$: filteredFlags = filterFlags(flagSearch, data.flags);
|
||||
|
||||
const filterFlags = (search: string, flags: PrideFlag[]) => {
|
||||
return (
|
||||
search
|
||||
? flags.filter((flag) => flag.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()))
|
||||
: flags
|
||||
).slice(0, 25);
|
||||
};
|
||||
|
||||
let preferenceIds: string[];
|
||||
$: preferenceIds = Object.keys(custom_preferences);
|
||||
|
||||
|
@ -76,6 +91,7 @@
|
|||
names,
|
||||
pronouns,
|
||||
fields,
|
||||
flags,
|
||||
avatar,
|
||||
member_title,
|
||||
list_private,
|
||||
|
@ -91,6 +107,7 @@
|
|||
names: FieldEntry[],
|
||||
pronouns: Pronoun[],
|
||||
fields: Field[],
|
||||
flags: PrideFlag[],
|
||||
avatar: string | null,
|
||||
member_title: string,
|
||||
list_private: boolean,
|
||||
|
@ -101,6 +118,7 @@
|
|||
if (member_title !== (user.member_title || "")) return true;
|
||||
if (!linksEqual(links, user.links)) return true;
|
||||
if (!fieldsEqual(fields, user.fields)) return true;
|
||||
if (!flagsEqual(flags, user.flags)) return true;
|
||||
if (!namesEqual(names, user.names)) return true;
|
||||
if (!pronounsEqual(pronouns, user.pronouns)) return true;
|
||||
if (!customPreferencesEqual(custom_preferences, user.custom_preferences)) return true;
|
||||
|
@ -145,6 +163,11 @@
|
|||
return arr1.every((_, i) => arr1[i] === arr2[i]);
|
||||
};
|
||||
|
||||
const flagsEqual = (arr1: PrideFlag[], arr2: PrideFlag[]) => {
|
||||
if (arr1.length !== arr2.length) return false;
|
||||
return arr1.every((_, i) => arr1[i].id === arr2[i].id);
|
||||
};
|
||||
|
||||
const customPreferencesEqual = (obj1: CustomPreferences, obj2: CustomPreferences) => {
|
||||
if (Object.keys(obj2).some((key) => !(key in obj1))) return false;
|
||||
|
||||
|
@ -227,6 +250,26 @@
|
|||
links[newIndex] = temp;
|
||||
};
|
||||
|
||||
const moveFlag = (index: number, up: boolean) => {
|
||||
if (up && index == 0) return;
|
||||
if (!up && index == flags.length - 1) return;
|
||||
|
||||
const newIndex = up ? index - 1 : index + 1;
|
||||
|
||||
const temp = flags[index];
|
||||
flags[index] = flags[newIndex];
|
||||
flags[newIndex] = temp;
|
||||
};
|
||||
|
||||
const addFlag = (flag: PrideFlag) => {
|
||||
flags = [...flags, flag];
|
||||
};
|
||||
|
||||
const removeFlag = (index: number) => {
|
||||
flags.splice(index, 1);
|
||||
flags = [...flags];
|
||||
};
|
||||
|
||||
const addName = (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
|
@ -317,6 +360,7 @@
|
|||
member_title,
|
||||
list_private,
|
||||
custom_preferences,
|
||||
flags: flags.map((flag) => flag.id),
|
||||
});
|
||||
|
||||
data.user = resp;
|
||||
|
@ -516,6 +560,72 @@
|
|||
</Button>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane tabId="flags" tab="Flags">
|
||||
<div class="mt-3">
|
||||
{#each flags as _, index}
|
||||
<ButtonGroup class="m-1">
|
||||
<IconButton
|
||||
icon="chevron-left"
|
||||
color="secondary"
|
||||
tooltip="Move flag to the left"
|
||||
click={() => moveFlag(index, true)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-right"
|
||||
color="secondary"
|
||||
tooltip="Move flag to the right"
|
||||
click={() => moveFlag(index, false)}
|
||||
/>
|
||||
<FlagButton
|
||||
flag={flags[index]}
|
||||
tooltip="Remove this flag from your profile"
|
||||
on:click={() => removeFlag(index)}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
{/each}
|
||||
</div>
|
||||
<hr />
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<Input
|
||||
placeholder="Filter flags"
|
||||
bind:value={flagSearch}
|
||||
disabled={data.flags.length === 0}
|
||||
/>
|
||||
<div class="p-2">
|
||||
{#each filteredFlags as flag (flag.id)}
|
||||
<FlagButton
|
||||
{flag}
|
||||
tooltip="Add this flag to your profile"
|
||||
on:click={() => addFlag(flag)}
|
||||
/>
|
||||
{:else}
|
||||
{#if data.flags.length === 0}
|
||||
You haven't uploaded any flags yet.
|
||||
{:else}
|
||||
There are no flags matching your search <strong>{flagSearch}</strong>.
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<Alert color="secondary" fade={false}>
|
||||
{#if data.flags.length === 0}
|
||||
<p><strong>Why can't I see any flags?</strong></p>
|
||||
<p>
|
||||
There are thousands of pride flags, and it would be impossible to bundle all of them
|
||||
by default. Many labels also have multiple different flags that are favoured by
|
||||
different people. Because of this, there are no flags available by default--instead,
|
||||
you can upload flags in your <a href="/settings/flags">settings</a>. Your main profile
|
||||
and your member profiles can all have different flags.
|
||||
</p>
|
||||
{:else}
|
||||
To upload and delete flags, go to your <a href="/settings/flags">settings</a>.
|
||||
{/if}
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane tabId="links" tab="Links">
|
||||
<div class="mt-3">
|
||||
{#each links as _, index}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { APIError, MeUser, PronounsJson } from "$lib/api/entities";
|
||||
import type { PrideFlag, APIError, MeUser, PronounsJson } from "$lib/api/entities";
|
||||
import { apiFetchClient } from "$lib/api/fetch";
|
||||
import { error } from "@sveltejs/kit";
|
||||
|
||||
|
@ -10,10 +10,12 @@ export const ssr = false;
|
|||
export const load = async () => {
|
||||
try {
|
||||
const user = await apiFetchClient<MeUser>(`/users/@me`);
|
||||
const flags = await apiFetchClient<PrideFlag[]>("/users/@me/flags");
|
||||
|
||||
return {
|
||||
user,
|
||||
pronouns: pronouns.autocomplete,
|
||||
flags,
|
||||
};
|
||||
} catch (e) {
|
||||
throw error((e as APIError).code, (e as APIError).message);
|
||||
|
|
|
@ -42,20 +42,13 @@
|
|||
|
||||
<div class="grid">
|
||||
<div class="row">
|
||||
<div class="col-md-3 m-3">
|
||||
<div class="col-md-3 p-3">
|
||||
<h1>Settings</h1>
|
||||
|
||||
<ListGroup>
|
||||
<ListGroupItem tag="a" active={$page.url.pathname === "/settings"} href="/settings">
|
||||
Your profile
|
||||
</ListGroupItem>
|
||||
<ListGroupItem
|
||||
tag="a"
|
||||
active={$page.url.pathname === "/settings/auth"}
|
||||
href="/settings/auth"
|
||||
>
|
||||
Authentication
|
||||
</ListGroupItem>
|
||||
{#if hasHiddenMembers}
|
||||
<ListGroupItem
|
||||
tag="a"
|
||||
|
@ -65,6 +58,14 @@
|
|||
Hidden members
|
||||
</ListGroupItem>
|
||||
{/if}
|
||||
<ListGroupItem
|
||||
tag="a"
|
||||
active={$page.url.pathname === "/settings/flags"}
|
||||
href="/settings/flags">Flags</ListGroupItem
|
||||
>
|
||||
</ListGroup>
|
||||
<br />
|
||||
<ListGroup>
|
||||
{#if data.invitesEnabled}
|
||||
<ListGroupItem
|
||||
tag="a"
|
||||
|
@ -101,7 +102,7 @@
|
|||
<ListGroupItem tag="button" on:click={toggle}>Log out</ListGroupItem>
|
||||
</ListGroup>
|
||||
</div>
|
||||
<div class="col-md m-3">
|
||||
<div class="col-md p-3">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -155,8 +155,9 @@
|
|||
{/if}
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<FallbackImage width={200} urls={userAvatars(data.user)} alt="Your avatar" />
|
||||
<p>
|
||||
<p class="text-center">
|
||||
<FallbackImage width={200} urls={userAvatars(data.user)} alt="Your avatar" />
|
||||
<br />
|
||||
To change your avatar, go to <a href="/edit/profile">edit profile</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
|
180
frontend/src/routes/settings/flags/+page.svelte
Normal file
180
frontend/src/routes/settings/flags/+page.svelte
Normal file
|
@ -0,0 +1,180 @@
|
|||
<script lang="ts">
|
||||
import type { APIError, PrideFlag } from "$lib/api/entities";
|
||||
import { Button, Icon, Input, Modal, ModalBody, ModalFooter, ModalHeader } from "sveltestrap";
|
||||
import type { PageData } from "./$types";
|
||||
import Flag from "./Flag.svelte";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { addToast } from "$lib/toast";
|
||||
import { encode } from "base64-arraybuffer";
|
||||
import unknownFlag from "./unknown_flag.png";
|
||||
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch";
|
||||
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
||||
|
||||
const MAX_FLAG_BYTES = 500_000;
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let search = "";
|
||||
let error: APIError | null = null;
|
||||
|
||||
let filtered: PrideFlag[];
|
||||
$: filtered = filterFlags(search, data.flags);
|
||||
|
||||
const filterFlags = (search: string, flags: PrideFlag[]) => {
|
||||
return search
|
||||
? flags.filter((flag) => flag.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()))
|
||||
: flags;
|
||||
};
|
||||
|
||||
// NEW FLAG UPLOADING CODE
|
||||
let modalOpen = false;
|
||||
const toggleModal = () => (modalOpen = !modalOpen);
|
||||
let canUpload: boolean;
|
||||
$: canUpload = !!(newFlag && newName);
|
||||
|
||||
let newFlag: string | null;
|
||||
let flagFiles: FileList | null;
|
||||
$: getFlag(flagFiles).then((b64) => (newFlag = b64));
|
||||
|
||||
let newName = "";
|
||||
let newDescription = "";
|
||||
|
||||
const getFlag = async (list: FileList | null) => {
|
||||
if (!list || list.length === 0) return null;
|
||||
if (list[0].size > MAX_FLAG_BYTES) {
|
||||
addToast({
|
||||
header: "Flag too large",
|
||||
body: `This flag file is too large, please resize it (maximum is ${prettyBytes(
|
||||
MAX_FLAG_BYTES,
|
||||
)}, the file you tried to upload is ${prettyBytes(list[0].size)})`,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffer = await list[0].arrayBuffer();
|
||||
const base64 = encode(buffer);
|
||||
|
||||
const uri = `data:${list[0].type};base64,${base64}`;
|
||||
|
||||
return uri;
|
||||
};
|
||||
|
||||
const uploadFlag = async () => {
|
||||
try {
|
||||
const resp = await apiFetchClient<PrideFlag>("/users/@me/flags", "POST", {
|
||||
flag: newFlag,
|
||||
name: newName,
|
||||
description: newDescription || null,
|
||||
});
|
||||
|
||||
error = null;
|
||||
data.flags.push(resp);
|
||||
data.flags.sort((a, b) => a.name.localeCompare(b.name));
|
||||
data.flags = [...data.flags];
|
||||
|
||||
// reset flag
|
||||
newFlag = null;
|
||||
newName = "";
|
||||
newDescription = "";
|
||||
|
||||
addToast({ header: "Uploaded flag", body: "Successfully uploaded flag!" });
|
||||
toggleModal();
|
||||
} catch (e) {
|
||||
error = e as APIError;
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE FLAG CODE
|
||||
const deleteFlag = async (id: string) => {
|
||||
try {
|
||||
await fastFetchClient(`/users/@me/flags/${id}`, "DELETE");
|
||||
|
||||
error = null;
|
||||
|
||||
addToast({ header: "Deleted flag", body: "Successfully deleted flag!" });
|
||||
data.flags = data.flags.filter((entry) => entry.id !== id);
|
||||
} catch (e) {
|
||||
error = e as APIError;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<h1>Pride flags ({data.flags.length})</h1>
|
||||
|
||||
<p>
|
||||
You can upload pride flags to use on your profiles here. Flags you upload here will <em>not</em> automatically
|
||||
show up on your profile.
|
||||
</p>
|
||||
|
||||
<div class="input-group">
|
||||
<Input placeholder="Filter flags" bind:value={search} disabled={data.flags.length === 0} />
|
||||
<Button color="success" on:click={toggleModal}>
|
||||
<Icon name="upload" aria-hidden /> Upload flag
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
{#each filtered as flag (flag.id)}
|
||||
<Flag bind:flag {deleteFlag} />
|
||||
{:else}
|
||||
{#if data.flags.length === 0}
|
||||
You haven't uploaded any flags yet, press the button above to do so.
|
||||
{:else}
|
||||
There are no flags matching your search <strong>{search}</strong>.
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Modal isOpen={modalOpen} toggle={toggleModal}>
|
||||
<ModalHeader toggle={toggleModal}>Upload flag</ModalHeader>
|
||||
<ModalBody>
|
||||
{#if error}
|
||||
<ErrorAlert {error} />
|
||||
{/if}
|
||||
<div class="d-flex align-items-center">
|
||||
<img src={newFlag || unknownFlag} alt="New flag" class="flag m-1" />
|
||||
<input
|
||||
class="form-control"
|
||||
id="flag-file"
|
||||
type="file"
|
||||
bind:files={flagFiles}
|
||||
accept="image/png, image/jpeg, image/gif, image/webp, image/svg+xml"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-muted mt-2">
|
||||
<Icon name="info-circle-fill" aria-hidden /> Only PNG, JPEG, GIF, and WebP images can be uploaded
|
||||
as flags. The file cannot be larger than 512 kilobytes.
|
||||
</p>
|
||||
<p>
|
||||
<label for="newName" class="form-label">Name</label>
|
||||
<Input id="newName" bind:value={newName} />
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
<Icon name="info-circle-fill" aria-hidden /> This name will be shown beside the flag.
|
||||
</p>
|
||||
<p>
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea id="description" class="form-control" bind:value={newDescription} />
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
<Icon name="info-circle-fill" aria-hidden /> This text will be used as the alt text of the flag
|
||||
image, and will also be shown on hover. Optional, but <strong>strongly recommended</strong> as
|
||||
it improves accessibility.
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button disabled={!canUpload} color="success" on:click={() => uploadFlag()}>Upload flag</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.flag {
|
||||
height: 2rem;
|
||||
max-width: 200px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 100px;
|
||||
}
|
||||
</style>
|
7
frontend/src/routes/settings/flags/+page.ts
Normal file
7
frontend/src/routes/settings/flags/+page.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { apiFetchClient } from "$lib/api/fetch";
|
||||
import type { PrideFlag } from "$lib/api/entities";
|
||||
|
||||
export const load = async () => {
|
||||
const data = await apiFetchClient<PrideFlag[]>("/users/@me/flags");
|
||||
return { flags: data };
|
||||
};
|
84
frontend/src/routes/settings/flags/Flag.svelte
Normal file
84
frontend/src/routes/settings/flags/Flag.svelte
Normal file
|
@ -0,0 +1,84 @@
|
|||
<script lang="ts">
|
||||
import { flagURL, type APIError, type PrideFlag } from "$lib/api/entities";
|
||||
import { apiFetchClient } from "$lib/api/fetch";
|
||||
import { addToast } from "$lib/toast";
|
||||
import { Button, Input, Modal, ModalBody, ModalFooter, ModalHeader } from "sveltestrap";
|
||||
|
||||
export let flag: PrideFlag;
|
||||
export let deleteFlag: (id: string) => Promise<void>;
|
||||
|
||||
let error: APIError | null = null;
|
||||
|
||||
let modalOpen = false;
|
||||
const toggleModal = () => (modalOpen = !modalOpen);
|
||||
|
||||
let deleteModalOpen = false;
|
||||
const toggleDeleteModal = () => (deleteModalOpen = !deleteModalOpen);
|
||||
|
||||
let name = flag.name;
|
||||
let description = flag.description;
|
||||
|
||||
const updateFlag = async () => {
|
||||
try {
|
||||
const resp = await apiFetchClient<PrideFlag>(`/users/@me/flags/${flag.id}`, "PATCH", {
|
||||
name,
|
||||
description: description || null,
|
||||
});
|
||||
|
||||
error = null;
|
||||
flag = resp;
|
||||
|
||||
addToast({ header: "Updated flag", body: "Successfully updated flag!" });
|
||||
toggleModal();
|
||||
} catch (e) {
|
||||
error = e as APIError;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Button outline class="m-1" on:click={toggleModal}>
|
||||
<img class="flag" src={flagURL(flag)} alt={flag.description ?? flag.name} />
|
||||
{flag.name}
|
||||
</Button>
|
||||
|
||||
<Modal isOpen={modalOpen} toggle={toggleModal}>
|
||||
<ModalHeader toggle={toggleModal}>Edit {flag.name} flag</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>
|
||||
<label for="name" class="form-label">Name</label>
|
||||
<Input id="name" bind:value={name} />
|
||||
</p>
|
||||
<p>
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea id="description" class="form-control" bind:value={description} />
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" on:click={toggleDeleteModal}>Delete flag</Button>
|
||||
<Button disabled={!name} color="success" on:click={() => updateFlag()}>Edit flag</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={deleteModalOpen} toggle={toggleDeleteModal}>
|
||||
<ModalHeader toggle={toggleDeleteModal}>Delete {flag.name} flag</ModalHeader>
|
||||
<ModalBody>
|
||||
Are you sure you want to delete the {flag.name} flag? <strong>This cannot be undone!</strong>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" on:click={() => deleteFlag(flag.id)}>Delete flag</Button>
|
||||
<Button color="secondary" on:click={toggleDeleteModal}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.flag {
|
||||
height: 2rem;
|
||||
max-width: 200px;
|
||||
border-radius: 3px;
|
||||
margin-left: -5px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 100px;
|
||||
}
|
||||
</style>
|
BIN
frontend/src/routes/settings/flags/unknown_flag.png
Normal file
BIN
frontend/src/routes/settings/flags/unknown_flag.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.7 KiB |
24
scripts/migrate/017_pride_flags.sql
Normal file
24
scripts/migrate/017_pride_flags.sql
Normal file
|
@ -0,0 +1,24 @@
|
|||
-- +migrate Up
|
||||
|
||||
-- 2023-05-09: Add pride flags
|
||||
-- Hashes are a separate table so we can deduplicate flags.
|
||||
|
||||
create table pride_flags (
|
||||
id text primary key,
|
||||
user_id text not null references users (id) on delete cascade,
|
||||
hash text not null,
|
||||
name text not null,
|
||||
description text
|
||||
);
|
||||
|
||||
create table user_flags (
|
||||
id bigint generated by default as identity primary key,
|
||||
user_id text not null references users (id) on delete cascade,
|
||||
flag_id text not null references pride_flags (id) on delete cascade
|
||||
);
|
||||
|
||||
create table member_flags (
|
||||
id bigint generated by default as identity primary key,
|
||||
member_id text not null references members (id) on delete cascade,
|
||||
flag_id text not null references pride_flags (id) on delete cascade
|
||||
);
|
Loading…
Reference in a new issue