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 ErrInvalidDataURI = errors.Sentinel("invalid data URI") | ||||||
| const ErrInvalidContentType = errors.Sentinel("invalid avatar content type") | 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. | // ConvertAvatar parses an avatar from a data URI, converts it to WebP and JPEG, and returns the results. | ||||||
| func (db *DB) ConvertAvatar(data string) ( | 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 ErrNothingToUpdate = errors.Sentinel("nothing to update") | ||||||
| 
 | 
 | ||||||
|  | const ( | ||||||
|  | 	uniqueViolation     = "23505" | ||||||
|  | 	foreignKeyViolation = "23503" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
| type Execer interface { | type Execer interface { | ||||||
| 	Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error) | 	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{} | 		pge := &pgconn.PgError{} | ||||||
| 		if errors.As(err, &pge) { | 		if errors.As(err, &pge) { | ||||||
| 			// unique constraint violation | 			// unique constraint violation | ||||||
| 			if pge.Code == "23505" { | 			if pge.Code == uniqueViolation { | ||||||
| 				return m, ErrMemberNameInUse | 				return m, ErrMemberNameInUse | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  | @ -223,7 +223,7 @@ func (db *DB) UpdateMember( | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		pge := &pgconn.PgError{} | 		pge := &pgconn.PgError{} | ||||||
| 		if errors.As(err, &pge) { | 		if errors.As(err, &pge) { | ||||||
| 			if pge.Code == "23505" { | 			if pge.Code == uniqueViolation { | ||||||
| 				return m, ErrMemberNameInUse | 				return m, ErrMemberNameInUse | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | @ -171,7 +171,7 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use | ||||||
| 		pge := &pgconn.PgError{} | 		pge := &pgconn.PgError{} | ||||||
| 		if errors.As(err, &pge) { | 		if errors.As(err, &pge) { | ||||||
| 			// unique constraint violation | 			// unique constraint violation | ||||||
| 			if pge.Code == "23505" { | 			if pge.Code == uniqueViolation { | ||||||
| 				return u, ErrUsernameTaken | 				return u, ErrUsernameTaken | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  | @ -494,7 +494,7 @@ func (db *DB) UpdateUsername(ctx context.Context, tx pgx.Tx, id xid.ID, newName | ||||||
| 		pge := &pgconn.PgError{} | 		pge := &pgconn.PgError{} | ||||||
| 		if errors.As(err, &pge) { | 		if errors.As(err, &pge) { | ||||||
| 			// unique constraint violation | 			// unique constraint violation | ||||||
| 			if pge.Code == "23505" { | 			if pge.Code == uniqueViolation { | ||||||
| 				return ErrUsernameTaken | 				return ErrUsernameTaken | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | @ -188,7 +188,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error | ||||||
| 		return errors.Wrap(err, "committing transaction") | 		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 | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -23,13 +23,14 @@ type GetMemberResponse struct { | ||||||
| 	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"` | ||||||
|  | 	Flags    []db.MemberFlag   `json:"flags"` | ||||||
| 
 | 
 | ||||||
| 	User PartialUser `json:"user"` | 	User PartialUser `json:"user"` | ||||||
| 
 | 
 | ||||||
| 	Unlisted *bool `json:"unlisted,omitempty"` | 	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{ | 	r := GetMemberResponse{ | ||||||
| 		ID:          m.ID, | 		ID:          m.ID, | ||||||
| 		Name:        m.Name, | 		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), | 		Names:    db.NotNull(m.Names), | ||||||
| 		Pronouns: db.NotNull(m.Pronouns), | 		Pronouns: db.NotNull(m.Pronouns), | ||||||
| 		Fields:   db.NotNull(fields), | 		Fields:   db.NotNull(fields), | ||||||
|  | 		Flags:    flags, | ||||||
| 
 | 
 | ||||||
| 		User: PartialUser{ | 		User: PartialUser{ | ||||||
| 			ID:                u.ID, | 			ID:                u.ID, | ||||||
|  | @ -102,7 +104,12 @@ func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error { | ||||||
| 		return err | 		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 | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -137,7 +144,12 @@ func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error { | ||||||
| 		return err | 		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 | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -25,6 +25,7 @@ type PatchMemberRequest struct { | ||||||
| 	Fields      *[]db.Field        `json:"fields"` | 	Fields      *[]db.Field        `json:"fields"` | ||||||
| 	Avatar      *string            `json:"avatar"` | 	Avatar      *string            `json:"avatar"` | ||||||
| 	Unlisted    *bool              `json:"unlisted"` | 	Unlisted    *bool              `json:"unlisted"` | ||||||
|  | 	Flags       *[]xid.ID          `json:"flags"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { | 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.Fields == nil && | ||||||
| 		req.Names == nil && | 		req.Names == nil && | ||||||
| 		req.Pronouns == nil && | 		req.Pronouns == nil && | ||||||
| 		req.Avatar == nil { | 		req.Avatar == nil && | ||||||
|  | 		req.Flags == 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", | ||||||
|  | @ -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 { | 	if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil { | ||||||
| 		return *err | 		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 | 	// update last active time | ||||||
| 	err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) | 	err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  | @ -283,7 +308,14 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { | ||||||
| 		return err | 		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 | 	// 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 | 	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"` | 	Members           []PartialMember      `json:"members"` | ||||||
| 	Fields            []db.Field           `json:"fields"` | 	Fields            []db.Field           `json:"fields"` | ||||||
| 	CustomPreferences db.CustomPreferences `json:"custom_preferences"` | 	CustomPreferences db.CustomPreferences `json:"custom_preferences"` | ||||||
|  | 	Flags             []db.UserFlag        `json:"flags"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type GetMeResponse struct { | type GetMeResponse struct { | ||||||
|  | @ -61,7 +62,7 @@ type PartialMember struct { | ||||||
| 	Pronouns    []db.PronounEntry `json:"pronouns"` | 	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{ | 	resp := GetUserResponse{ | ||||||
| 		ID:                u.ID, | 		ID:                u.ID, | ||||||
| 		Username:          u.Username, | 		Username:          u.Username, | ||||||
|  | @ -74,6 +75,7 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUser | ||||||
| 		Pronouns:          db.NotNull(u.Pronouns), | 		Pronouns:          db.NotNull(u.Pronouns), | ||||||
| 		Fields:            db.NotNull(fields), | 		Fields:            db.NotNull(fields), | ||||||
| 		CustomPreferences: u.CustomPreferences, | 		CustomPreferences: u.CustomPreferences, | ||||||
|  | 		Flags:             flags, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	resp.Members = make([]PartialMember, len(members)) | 	resp.Members = make([]PartialMember, len(members)) | ||||||
|  | @ -93,56 +95,29 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUser | ||||||
| 	return resp | 	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() | 	ctx := r.Context() | ||||||
| 
 | 
 | ||||||
| 	userRef := chi.URLParamFromCtx(ctx, "userRef") | 	userRef := chi.URLParamFromCtx(ctx, "userRef") | ||||||
| 
 | 
 | ||||||
|  | 	var u db.User | ||||||
| 	if id, err := xid.FromString(userRef); err == nil { | 	if id, err := xid.FromString(userRef); err == nil { | ||||||
| 		u, err := s.DB.User(ctx, id) | 		u, err = s.DB.User(ctx, id) | ||||||
| 		if err == nil { | 		if err != nil { | ||||||
| 			if u.DeletedAt != nil { | 			log.Errorf("getting user by ID: %v", err) | ||||||
| 				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 |  | ||||||
| 		} | 		} | ||||||
| 		// otherwise, we fall back to checking usernames |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	u, err := s.DB.Username(ctx, userRef) | 	if u.ID.IsNil() { | ||||||
| 	if err == db.ErrUserNotFound { | 		u, err = s.DB.Username(ctx, userRef) | ||||||
| 		return server.APIError{ | 		if err == db.ErrUserNotFound { | ||||||
| 			Code: server.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 { | 	if u.DeletedAt != nil { | ||||||
|  | @ -160,6 +135,12 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error { | ||||||
| 		return err | 		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 | 	var members []db.Member | ||||||
| 	if !u.ListPrivate || isSelf { | 	if !u.ListPrivate || isSelf { | ||||||
| 		members, err = s.DB.UserMembers(ctx, u.ID, 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 | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -195,8 +176,14 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error { | ||||||
| 		return err | 		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{ | 	render.JSON(w, r, GetMeResponse{ | ||||||
| 		GetUserResponse:   dbUserToResponse(u, fields, members), | 		GetUserResponse:   dbUserToResponse(u, fields, members, flags), | ||||||
| 		CreatedAt:         u.ID.Time(), | 		CreatedAt:         u.ID.Time(), | ||||||
| 		MaxInvites:        u.MaxInvites, | 		MaxInvites:        u.MaxInvites, | ||||||
| 		IsAdmin:           u.IsAdmin, | 		IsAdmin:           u.IsAdmin, | ||||||
|  |  | ||||||
|  | @ -11,6 +11,7 @@ import ( | ||||||
| 	"emperror.dev/errors" | 	"emperror.dev/errors" | ||||||
| 	"github.com/go-chi/render" | 	"github.com/go-chi/render" | ||||||
| 	"github.com/google/uuid" | 	"github.com/google/uuid" | ||||||
|  | 	"github.com/rs/xid" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type PatchUserRequest struct { | type PatchUserRequest struct { | ||||||
|  | @ -25,6 +26,7 @@ type PatchUserRequest struct { | ||||||
| 	Avatar            *string               `json:"avatar"` | 	Avatar            *string               `json:"avatar"` | ||||||
| 	ListPrivate       *bool                 `json:"list_private"` | 	ListPrivate       *bool                 `json:"list_private"` | ||||||
| 	CustomPreferences *db.CustomPreferences `json:"custom_preferences"` | 	CustomPreferences *db.CustomPreferences `json:"custom_preferences"` | ||||||
|  | 	Flags             *[]xid.ID             `json:"flags"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // patchUser parses a PatchUserRequest and updates the user with the given ID. | // 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.Names == nil && | ||||||
| 		req.Pronouns == nil && | 		req.Pronouns == nil && | ||||||
| 		req.Avatar == nil && | 		req.Avatar == nil && | ||||||
| 		req.CustomPreferences == nil { | 		req.CustomPreferences == nil && | ||||||
|  | 		req.Flags == 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", | ||||||
|  | @ -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 | 	// validate custom preferences | ||||||
| 	if req.CustomPreferences != nil { | 	if req.CustomPreferences != nil { | ||||||
| 		if count := len(*req.CustomPreferences); count > db.MaxFields { | 		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 | 	// update last active time | ||||||
| 	err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) | 	err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) | ||||||
| 	if err != nil { | 	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 | 	// echo the updated user back on success | ||||||
| 	render.JSON(w, r, GetMeResponse{ | 	render.JSON(w, r, GetMeResponse{ | ||||||
| 		GetUserResponse:   dbUserToResponse(u, fields, nil), | 		GetUserResponse:   dbUserToResponse(u, fields, nil, flags), | ||||||
| 		MaxInvites:        u.MaxInvites, | 		MaxInvites:        u.MaxInvites, | ||||||
| 		IsAdmin:           u.IsAdmin, | 		IsAdmin:           u.IsAdmin, | ||||||
| 		ListPrivate:       u.ListPrivate, | 		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/start", server.WrapHandler(s.startExport)) | ||||||
| 			r.Get("/@me/export", server.WrapHandler(s.getExport)) | 			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 | 	// User-related error codes | ||||||
| 	ErrUserNotFound      = 2001 | 	ErrUserNotFound      = 2001 | ||||||
| 	ErrMemberListPrivate = 2002 | 	ErrMemberListPrivate = 2002 | ||||||
|  | 	ErrFlagLimitReached  = 2003 | ||||||
| 
 | 
 | ||||||
| 	// Member-related error codes | 	// Member-related error codes | ||||||
| 	ErrMemberNotFound     = 3001 | 	ErrMemberNotFound     = 3001 | ||||||
|  | @ -145,7 +146,8 @@ var errCodeMessages = map[int]string{ | ||||||
| 	ErrInvalidCaptcha:      "Invalid or missing captcha response", | 	ErrInvalidCaptcha:      "Invalid or missing captcha response", | ||||||
| 
 | 
 | ||||||
| 	ErrUserNotFound:      "User not found", | 	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", | 	ErrMemberNotFound:     "Member not found", | ||||||
| 	ErrMemberLimitReached: "Member limit reached", | 	ErrMemberLimitReached: "Member limit reached", | ||||||
|  | @ -187,6 +189,7 @@ var errCodeStatuses = map[int]int{ | ||||||
| 
 | 
 | ||||||
| 	ErrUserNotFound:      http.StatusNotFound, | 	ErrUserNotFound:      http.StatusNotFound, | ||||||
| 	ErrMemberListPrivate: http.StatusForbidden, | 	ErrMemberListPrivate: http.StatusForbidden, | ||||||
|  | 	ErrFlagLimitReached:  http.StatusBadRequest, | ||||||
| 
 | 
 | ||||||
| 	ErrMemberNotFound:     http.StatusNotFound, | 	ErrMemberNotFound:     http.StatusNotFound, | ||||||
| 	ErrMemberLimitReached: http.StatusBadRequest, | 	ErrMemberLimitReached: http.StatusBadRequest, | ||||||
|  |  | ||||||
|  | @ -17,6 +17,7 @@ export interface User { | ||||||
|   pronouns: Pronoun[]; |   pronouns: Pronoun[]; | ||||||
|   members: PartialMember[]; |   members: PartialMember[]; | ||||||
|   fields: Field[]; |   fields: Field[]; | ||||||
|  |   flags: PrideFlag[]; | ||||||
|   custom_preferences: CustomPreferences; |   custom_preferences: CustomPreferences; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -83,6 +84,7 @@ export interface PartialMember { | ||||||
| 
 | 
 | ||||||
| export interface Member extends PartialMember { | export interface Member extends PartialMember { | ||||||
|   fields: Field[]; |   fields: Field[]; | ||||||
|  |   flags: PrideFlag[]; | ||||||
| 
 | 
 | ||||||
|   user: MemberPartialUser; |   user: MemberPartialUser; | ||||||
|   unlisted?: boolean; |   unlisted?: boolean; | ||||||
|  | @ -96,6 +98,13 @@ export interface MemberPartialUser { | ||||||
|   custom_preferences: CustomPreferences; |   custom_preferences: CustomPreferences; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export interface PrideFlag { | ||||||
|  |   id: string; | ||||||
|  |   hash: string; | ||||||
|  |   name: string; | ||||||
|  |   description: string | null; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export interface Invite { | export interface Invite { | ||||||
|   code: string; |   code: string; | ||||||
|   created: 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 = [ | export const defaultAvatars = [ | ||||||
|   `${PUBLIC_BASE_URL}/default/512.webp`, |   `${PUBLIC_BASE_URL}/default/512.webp`, | ||||||
|   `${PUBLIC_BASE_URL}/default/512.jpg`, |   `${PUBLIC_BASE_URL}/default/512.jpg`, | ||||||
|  |  | ||||||
|  | @ -40,6 +40,7 @@ | ||||||
|   import StatusLine from "$lib/components/StatusLine.svelte"; |   import StatusLine from "$lib/components/StatusLine.svelte"; | ||||||
|   import defaultPreferences from "$lib/api/default_preferences"; |   import defaultPreferences from "$lib/api/default_preferences"; | ||||||
|   import { addToast } from "$lib/toast"; |   import { addToast } from "$lib/toast"; | ||||||
|  |   import ProfileFlag from "./ProfileFlag.svelte"; | ||||||
| 
 | 
 | ||||||
|   export let data: PageData; |   export let data: PageData; | ||||||
| 
 | 
 | ||||||
|  | @ -117,16 +118,16 @@ | ||||||
| 
 | 
 | ||||||
|   onMount(async () => { |   onMount(async () => { | ||||||
|     if ($userStore && $userStore.id === data.id) { |     if ($userStore && $userStore.id === data.id) { | ||||||
|       console.log("User is current user, fetching members") |       console.log("User is current user, fetching members"); | ||||||
|       try { |       try { | ||||||
|         const members = await apiFetchClient<PartialMember[]>("/users/@me/members"); |         const members = await apiFetchClient<PartialMember[]>("/users/@me/members"); | ||||||
|         data.members = members; |         data.members = members; | ||||||
|       } catch (e) { |       } catch (e) { | ||||||
|         // If it fails, we fail silently but log to console anyway |         // If it fails, we fail silently but log to console anyway | ||||||
|         console.error("Fetching members:", e) |         console.error("Fetching members:", e); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   }) |   }); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <div class="container"> | <div class="container"> | ||||||
|  | @ -140,6 +141,13 @@ | ||||||
|     <div class="row"> |     <div class="row"> | ||||||
|       <div class="col-md-4 text-center"> |       <div class="col-md-4 text-center"> | ||||||
|         <FallbackImage width={200} urls={userAvatars(data)} alt="Avatar for @{data.name}" /> |         <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> | ||||||
|       <div class="col-md"> |       <div class="col-md"> | ||||||
|         {#if data.display_name} |         {#if data.display_name} | ||||||
|  | @ -174,6 +182,13 @@ | ||||||
|         </div> |         </div> | ||||||
|       {/if} |       {/if} | ||||||
|     </div> |     </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"> |     <div class="row row-cols-1 row-cols-sm-2 row-cols-md-3"> | ||||||
|       {#if data.names.length > 0} |       {#if data.names.length > 0} | ||||||
|         <div class="col-md"> |         <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 StatusLine from "$lib/components/StatusLine.svelte"; | ||||||
|   import defaultPreferences from "$lib/api/default_preferences"; |   import defaultPreferences from "$lib/api/default_preferences"; | ||||||
|   import { addToast } from "$lib/toast"; |   import { addToast } from "$lib/toast"; | ||||||
|  |   import ProfileFlag from "../ProfileFlag.svelte"; | ||||||
| 
 | 
 | ||||||
|   export let data: PageData; |   export let data: PageData; | ||||||
| 
 | 
 | ||||||
|  | @ -69,6 +70,13 @@ | ||||||
|     <div class="row"> |     <div class="row"> | ||||||
|       <div class="col-md-4 text-center"> |       <div class="col-md-4 text-center"> | ||||||
|         <FallbackImage width={200} urls={memberAvatars(data)} alt="Avatar for @{data.name}" /> |         <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> | ||||||
|       <div class="col-md"> |       <div class="col-md"> | ||||||
|         <h2>{data.display_name ?? data.name}</h2> |         <h2>{data.display_name ?? data.name}</h2> | ||||||
|  | @ -97,6 +105,13 @@ | ||||||
|         </div> |         </div> | ||||||
|       {/if} |       {/if} | ||||||
|     </div> |     </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"> |     <div class="row row-cols-1 row-cols-sm-2 row-cols-md-3"> | ||||||
|       {#if data.names.length > 0} |       {#if data.names.length > 0} | ||||||
|         <div class="col-md"> |         <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 FieldEntry, | ||||||
|     type Member, |     type Member, | ||||||
|     type Pronoun, |     type Pronoun, | ||||||
|  |     type PrideFlag, | ||||||
|   } from "$lib/api/entities"; |   } from "$lib/api/entities"; | ||||||
|   import FallbackImage from "$lib/components/FallbackImage.svelte"; |   import FallbackImage from "$lib/components/FallbackImage.svelte"; | ||||||
|   import { |   import { | ||||||
|  | @ -40,6 +41,7 @@ | ||||||
|   import { memberNameRegex } from "$lib/api/regex"; |   import { memberNameRegex } from "$lib/api/regex"; | ||||||
|   import { charCount, renderMarkdown } from "$lib/utils"; |   import { charCount, renderMarkdown } from "$lib/utils"; | ||||||
|   import MarkdownHelp from "../../MarkdownHelp.svelte"; |   import MarkdownHelp from "../../MarkdownHelp.svelte"; | ||||||
|  |   import FlagButton from "../../FlagButton.svelte"; | ||||||
| 
 | 
 | ||||||
|   const MAX_AVATAR_BYTES = 1_000_000; |   const MAX_AVATAR_BYTES = 1_000_000; | ||||||
| 
 | 
 | ||||||
|  | @ -59,6 +61,7 @@ | ||||||
|   let names: FieldEntry[] = window.structuredClone(data.member.names); |   let names: FieldEntry[] = window.structuredClone(data.member.names); | ||||||
|   let pronouns: Pronoun[] = window.structuredClone(data.member.pronouns); |   let pronouns: Pronoun[] = window.structuredClone(data.member.pronouns); | ||||||
|   let fields: Field[] = window.structuredClone(data.member.fields); |   let fields: Field[] = window.structuredClone(data.member.fields); | ||||||
|  |   let flags: PrideFlag[] = window.structuredClone(data.member.flags); | ||||||
|   let unlisted: boolean = data.member.unlisted || false; |   let unlisted: boolean = data.member.unlisted || false; | ||||||
| 
 | 
 | ||||||
|   let memberNameValid = true; |   let memberNameValid = true; | ||||||
|  | @ -71,6 +74,18 @@ | ||||||
|   let newPronouns = ""; |   let newPronouns = ""; | ||||||
|   let newLink = ""; |   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; |   let modified = false; | ||||||
| 
 | 
 | ||||||
|   $: modified = isModified( |   $: modified = isModified( | ||||||
|  | @ -82,6 +97,7 @@ | ||||||
|     names, |     names, | ||||||
|     pronouns, |     pronouns, | ||||||
|     fields, |     fields, | ||||||
|  |     flags, | ||||||
|     avatar, |     avatar, | ||||||
|     unlisted, |     unlisted, | ||||||
|   ); |   ); | ||||||
|  | @ -96,6 +112,7 @@ | ||||||
|     names: FieldEntry[], |     names: FieldEntry[], | ||||||
|     pronouns: Pronoun[], |     pronouns: Pronoun[], | ||||||
|     fields: Field[], |     fields: Field[], | ||||||
|  |     flags: PrideFlag[], | ||||||
|     avatar: string | null, |     avatar: string | null, | ||||||
|     unlisted: boolean, |     unlisted: boolean, | ||||||
|   ) => { |   ) => { | ||||||
|  | @ -104,6 +121,7 @@ | ||||||
|     if (display_name !== member.display_name) return true; |     if (display_name !== member.display_name) return true; | ||||||
|     if (!linksEqual(links, member.links)) return true; |     if (!linksEqual(links, member.links)) return true; | ||||||
|     if (!fieldsEqual(fields, member.fields)) return true; |     if (!fieldsEqual(fields, member.fields)) return true; | ||||||
|  |     if (!flagsEqual(flags, member.flags)) return true; | ||||||
|     if (!namesEqual(names, member.names)) return true; |     if (!namesEqual(names, member.names)) return true; | ||||||
|     if (!pronounsEqual(pronouns, member.pronouns)) return true; |     if (!pronounsEqual(pronouns, member.pronouns)) return true; | ||||||
|     if (avatar !== null) return true; |     if (avatar !== null) return true; | ||||||
|  | @ -147,6 +165,11 @@ | ||||||
|     return arr1.every((_, i) => arr1[i] === arr2[i]); |     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) => { |   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) { | ||||||
|  | @ -211,6 +234,26 @@ | ||||||
|     links[newIndex] = temp; |     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) => { |   const addName = (event: Event) => { | ||||||
|     event.preventDefault(); |     event.preventDefault(); | ||||||
| 
 | 
 | ||||||
|  | @ -281,6 +324,7 @@ | ||||||
|         names, |         names, | ||||||
|         pronouns, |         pronouns, | ||||||
|         fields, |         fields, | ||||||
|  |         flags: flags.map((flag) => flag.id), | ||||||
|         unlisted, |         unlisted, | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|  | @ -541,6 +585,72 @@ | ||||||
|       </Button> |       </Button> | ||||||
|     </div> |     </div> | ||||||
|   </TabPane> |   </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"> |   <TabPane tabId="links" tab="Links"> | ||||||
|     <div class="mt-3"> |     <div class="mt-3"> | ||||||
|       {#each links as _, index} |       {#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 { apiFetchClient } from "$lib/api/fetch"; | ||||||
| import { error } from "@sveltejs/kit"; | import { error } from "@sveltejs/kit"; | ||||||
| 
 | 
 | ||||||
|  | @ -11,11 +11,13 @@ export const load = async ({ params }) => { | ||||||
|   try { |   try { | ||||||
|     const user = await apiFetchClient<MeUser>(`/users/@me`); |     const user = await apiFetchClient<MeUser>(`/users/@me`); | ||||||
|     const member = await apiFetchClient<Member>(`/members/${params.id}`); |     const member = await apiFetchClient<Member>(`/members/${params.id}`); | ||||||
|  |     const flags = await apiFetchClient<PrideFlag[]>("/users/@me/flags"); | ||||||
| 
 | 
 | ||||||
|     return { |     return { | ||||||
|       user, |       user, | ||||||
|       member, |       member, | ||||||
|       pronouns: pronouns.autocomplete, |       pronouns: pronouns.autocomplete, | ||||||
|  |       flags, | ||||||
|     }; |     }; | ||||||
|   } catch (e) { |   } catch (e) { | ||||||
|     throw error((e as APIError).code, (e as APIError).message); |     throw error((e as APIError).code, (e as APIError).message); | ||||||
|  |  | ||||||
|  | @ -9,6 +9,7 @@ | ||||||
|     type Pronoun, |     type Pronoun, | ||||||
|     PreferenceSize, |     PreferenceSize, | ||||||
|     type CustomPreferences, |     type CustomPreferences, | ||||||
|  |     type PrideFlag, | ||||||
|   } 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"; | ||||||
|  | @ -39,6 +40,7 @@ | ||||||
|   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"; |   import CustomPreference from "./CustomPreference.svelte"; | ||||||
|  |   import FlagButton from "../FlagButton.svelte"; | ||||||
| 
 | 
 | ||||||
|   const MAX_AVATAR_BYTES = 1_000_000; |   const MAX_AVATAR_BYTES = 1_000_000; | ||||||
| 
 | 
 | ||||||
|  | @ -53,6 +55,7 @@ | ||||||
|   let names: FieldEntry[] = window.structuredClone(data.user.names); |   let names: FieldEntry[] = window.structuredClone(data.user.names); | ||||||
|   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 flags: PrideFlag[] = window.structuredClone(data.user.flags); | ||||||
|   let list_private = data.user.list_private; |   let list_private = data.user.list_private; | ||||||
|   let custom_preferences = window.structuredClone(data.user.custom_preferences); |   let custom_preferences = window.structuredClone(data.user.custom_preferences); | ||||||
| 
 | 
 | ||||||
|  | @ -63,6 +66,18 @@ | ||||||
|   let newPronouns = ""; |   let newPronouns = ""; | ||||||
|   let newLink = ""; |   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[]; |   let preferenceIds: string[]; | ||||||
|   $: preferenceIds = Object.keys(custom_preferences); |   $: preferenceIds = Object.keys(custom_preferences); | ||||||
| 
 | 
 | ||||||
|  | @ -76,6 +91,7 @@ | ||||||
|     names, |     names, | ||||||
|     pronouns, |     pronouns, | ||||||
|     fields, |     fields, | ||||||
|  |     flags, | ||||||
|     avatar, |     avatar, | ||||||
|     member_title, |     member_title, | ||||||
|     list_private, |     list_private, | ||||||
|  | @ -91,6 +107,7 @@ | ||||||
|     names: FieldEntry[], |     names: FieldEntry[], | ||||||
|     pronouns: Pronoun[], |     pronouns: Pronoun[], | ||||||
|     fields: Field[], |     fields: Field[], | ||||||
|  |     flags: PrideFlag[], | ||||||
|     avatar: string | null, |     avatar: string | null, | ||||||
|     member_title: string, |     member_title: string, | ||||||
|     list_private: boolean, |     list_private: boolean, | ||||||
|  | @ -101,6 +118,7 @@ | ||||||
|     if (member_title !== (user.member_title || "")) return true; |     if (member_title !== (user.member_title || "")) return true; | ||||||
|     if (!linksEqual(links, user.links)) return true; |     if (!linksEqual(links, user.links)) return true; | ||||||
|     if (!fieldsEqual(fields, user.fields)) return true; |     if (!fieldsEqual(fields, user.fields)) return true; | ||||||
|  |     if (!flagsEqual(flags, user.flags)) 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 (!customPreferencesEqual(custom_preferences, user.custom_preferences)) return true; | ||||||
|  | @ -145,6 +163,11 @@ | ||||||
|     return arr1.every((_, i) => arr1[i] === arr2[i]); |     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) => { |   const customPreferencesEqual = (obj1: CustomPreferences, obj2: CustomPreferences) => { | ||||||
|     if (Object.keys(obj2).some((key) => !(key in obj1))) return false; |     if (Object.keys(obj2).some((key) => !(key in obj1))) return false; | ||||||
| 
 | 
 | ||||||
|  | @ -227,6 +250,26 @@ | ||||||
|     links[newIndex] = temp; |     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) => { |   const addName = (event: Event) => { | ||||||
|     event.preventDefault(); |     event.preventDefault(); | ||||||
| 
 | 
 | ||||||
|  | @ -317,6 +360,7 @@ | ||||||
|         member_title, |         member_title, | ||||||
|         list_private, |         list_private, | ||||||
|         custom_preferences, |         custom_preferences, | ||||||
|  |         flags: flags.map((flag) => flag.id), | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|       data.user = resp; |       data.user = resp; | ||||||
|  | @ -516,6 +560,72 @@ | ||||||
|       </Button> |       </Button> | ||||||
|     </div> |     </div> | ||||||
|   </TabPane> |   </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"> |   <TabPane tabId="links" tab="Links"> | ||||||
|     <div class="mt-3"> |     <div class="mt-3"> | ||||||
|       {#each links as _, index} |       {#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 { apiFetchClient } from "$lib/api/fetch"; | ||||||
| import { error } from "@sveltejs/kit"; | import { error } from "@sveltejs/kit"; | ||||||
| 
 | 
 | ||||||
|  | @ -10,10 +10,12 @@ export const ssr = false; | ||||||
| export const load = async () => { | export const load = async () => { | ||||||
|   try { |   try { | ||||||
|     const user = await apiFetchClient<MeUser>(`/users/@me`); |     const user = await apiFetchClient<MeUser>(`/users/@me`); | ||||||
|  |     const flags = await apiFetchClient<PrideFlag[]>("/users/@me/flags"); | ||||||
| 
 | 
 | ||||||
|     return { |     return { | ||||||
|       user, |       user, | ||||||
|       pronouns: pronouns.autocomplete, |       pronouns: pronouns.autocomplete, | ||||||
|  |       flags, | ||||||
|     }; |     }; | ||||||
|   } catch (e) { |   } catch (e) { | ||||||
|     throw error((e as APIError).code, (e as APIError).message); |     throw error((e as APIError).code, (e as APIError).message); | ||||||
|  |  | ||||||
|  | @ -42,20 +42,13 @@ | ||||||
| 
 | 
 | ||||||
| <div class="grid"> | <div class="grid"> | ||||||
|   <div class="row"> |   <div class="row"> | ||||||
|     <div class="col-md-3 m-3"> |     <div class="col-md-3 p-3"> | ||||||
|       <h1>Settings</h1> |       <h1>Settings</h1> | ||||||
| 
 | 
 | ||||||
|       <ListGroup> |       <ListGroup> | ||||||
|         <ListGroupItem tag="a" active={$page.url.pathname === "/settings"} href="/settings"> |         <ListGroupItem tag="a" active={$page.url.pathname === "/settings"} href="/settings"> | ||||||
|           Your profile |           Your profile | ||||||
|         </ListGroupItem> |         </ListGroupItem> | ||||||
|         <ListGroupItem |  | ||||||
|           tag="a" |  | ||||||
|           active={$page.url.pathname === "/settings/auth"} |  | ||||||
|           href="/settings/auth" |  | ||||||
|         > |  | ||||||
|           Authentication |  | ||||||
|         </ListGroupItem> |  | ||||||
|         {#if hasHiddenMembers} |         {#if hasHiddenMembers} | ||||||
|           <ListGroupItem |           <ListGroupItem | ||||||
|             tag="a" |             tag="a" | ||||||
|  | @ -65,6 +58,14 @@ | ||||||
|             Hidden members |             Hidden members | ||||||
|           </ListGroupItem> |           </ListGroupItem> | ||||||
|         {/if} |         {/if} | ||||||
|  |         <ListGroupItem | ||||||
|  |           tag="a" | ||||||
|  |           active={$page.url.pathname === "/settings/flags"} | ||||||
|  |           href="/settings/flags">Flags</ListGroupItem | ||||||
|  |         > | ||||||
|  |       </ListGroup> | ||||||
|  |       <br /> | ||||||
|  |       <ListGroup> | ||||||
|         {#if data.invitesEnabled} |         {#if data.invitesEnabled} | ||||||
|           <ListGroupItem |           <ListGroupItem | ||||||
|             tag="a" |             tag="a" | ||||||
|  | @ -101,7 +102,7 @@ | ||||||
|         <ListGroupItem tag="button" on:click={toggle}>Log out</ListGroupItem> |         <ListGroupItem tag="button" on:click={toggle}>Log out</ListGroupItem> | ||||||
|       </ListGroup> |       </ListGroup> | ||||||
|     </div> |     </div> | ||||||
|     <div class="col-md m-3"> |     <div class="col-md p-3"> | ||||||
|       <slot /> |       <slot /> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|  | @ -155,8 +155,9 @@ | ||||||
|       {/if} |       {/if} | ||||||
|     </div> |     </div> | ||||||
|     <div class="col-lg-4"> |     <div class="col-lg-4"> | ||||||
|       <FallbackImage width={200} urls={userAvatars(data.user)} alt="Your avatar" /> |       <p class="text-center"> | ||||||
|       <p> |         <FallbackImage width={200} urls={userAvatars(data.user)} alt="Your avatar" /> | ||||||
|  |         <br /> | ||||||
|         To change your avatar, go to <a href="/edit/profile">edit profile</a>. |         To change your avatar, go to <a href="/edit/profile">edit profile</a>. | ||||||
|       </p> |       </p> | ||||||
|     </div> |     </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…
	
	Add table
		Add a link
		
	
		Reference in a new issue