forked from mirrors/pronouns.cc
Compare commits
12 commits
main
...
feature/fl
Author | SHA1 | Date | |
---|---|---|---|
|
a09cc627f3 | ||
|
cb305c96c7 | ||
|
453bc42215 | ||
|
94484f883d | ||
|
9f22512afa | ||
|
f41d2d0ef1 | ||
|
c1bd9395e4 | ||
|
ed4809aed8 | ||
|
c52bbc9567 | ||
|
279b79ecd9 | ||
|
d23526fa6d | ||
|
5e50d5f1e9 |
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,13 +22,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,
|
||||||
|
@ -40,6 +41,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,
|
||||||
|
@ -101,7 +103,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -136,7 +143,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…
Reference in a new issue