Merge pull request 'add custom name/label/pronoun preferences (closes #42)' (#51) from feature/custom-preferences into main

Reviewed-on: https://codeberg.org/u1f320/pronouns.cc/pulls/51
This commit is contained in:
sam 2023-04-20 07:33:23 +00:00
commit 2a15c519f3
34 changed files with 2572 additions and 321 deletions

View file

@ -40,13 +40,13 @@ func (w *WordStatus) UnmarshalJSON(src []byte) error {
return nil return nil
} }
func (w WordStatus) Valid(extra ...WordStatus) bool { func (w WordStatus) Valid(extra CustomPreferences) bool {
if w == StatusFavourite || w == StatusOkay || w == StatusJokingly || w == StatusFriendsOnly || w == StatusAvoid { if w == StatusFavourite || w == StatusOkay || w == StatusJokingly || w == StatusFriendsOnly || w == StatusAvoid {
return true return true
} }
for i := range extra { for k := range extra {
if w == extra[i] { if string(w) == k {
return true return true
} }
} }
@ -58,7 +58,7 @@ type FieldEntry struct {
Status WordStatus `json:"status"` Status WordStatus `json:"status"`
} }
func (fe FieldEntry) Validate() string { func (fe FieldEntry) Validate(custom CustomPreferences) string {
if fe.Value == "" { if fe.Value == "" {
return "value cannot be empty" return "value cannot be empty"
} }
@ -67,7 +67,7 @@ func (fe FieldEntry) Validate() string {
return fmt.Sprintf("name must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(fe.Value))) return fmt.Sprintf("name must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(fe.Value)))
} }
if !fe.Status.Valid() { if !fe.Status.Valid(custom) {
return "status is invalid" return "status is invalid"
} }
@ -80,7 +80,7 @@ type PronounEntry struct {
Status WordStatus `json:"status"` Status WordStatus `json:"status"`
} }
func (p PronounEntry) Validate() string { func (p PronounEntry) Validate(custom CustomPreferences) string {
if p.Pronouns == "" { if p.Pronouns == "" {
return "pronouns cannot be empty" return "pronouns cannot be empty"
} }
@ -95,7 +95,7 @@ func (p PronounEntry) Validate() string {
return fmt.Sprintf("pronouns must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(p.Pronouns))) return fmt.Sprintf("pronouns must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(p.Pronouns)))
} }
if !p.Status.Valid() { if !p.Status.Valid(custom) {
return "status is invalid" return "status is invalid"
} }

View file

@ -24,7 +24,7 @@ type Field struct {
} }
// Validate validates this field. If it is invalid, a non-empty string is returned as error message. // Validate validates this field. If it is invalid, a non-empty string is returned as error message.
func (f Field) Validate() string { func (f Field) Validate(custom CustomPreferences) string {
if f.Name == "" { if f.Name == "" {
return "name cannot be empty" return "name cannot be empty"
} }
@ -42,7 +42,7 @@ func (f Field) Validate() string {
return fmt.Sprintf("entries.%d: max length is %d characters, length is %d", i, FieldEntryMaxLength, length) return fmt.Sprintf("entries.%d: max length is %d characters, length is %d", i, FieldEntryMaxLength, length)
} }
if !entry.Status.Valid() { if !entry.Status.Valid(custom) {
return fmt.Sprintf("entries.%d: status is invalid", i) return fmt.Sprintf("entries.%d: status is invalid", i)
} }
} }

View file

@ -4,9 +4,12 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt"
"regexp" "regexp"
"time" "time"
"codeberg.org/u1f320/pronouns.cc/backend/common"
"codeberg.org/u1f320/pronouns.cc/backend/icons"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/georgysavva/scany/v2/pgxscan" "github.com/georgysavva/scany/v2/pgxscan"
@ -49,8 +52,47 @@ type User struct {
DeletedAt *time.Time DeletedAt *time.Time
SelfDelete *bool SelfDelete *bool
DeleteReason *string DeleteReason *string
CustomPreferences CustomPreferences
} }
type CustomPreferences = map[string]CustomPreference
type CustomPreference struct {
Icon string `json:"icon"`
Tooltip string `json:"tooltip"`
Size PreferenceSize `json:"size"`
Muted bool `json:"muted"`
Favourite bool `json:"favourite"`
}
func (c CustomPreference) Validate() string {
if !icons.IsValid(c.Icon) {
return fmt.Sprintf("custom preference icon %q is invalid", c.Icon)
}
if c.Tooltip == "" {
return "custom preference tooltip is empty"
}
if common.StringLength(&c.Tooltip) > FieldEntryMaxLength {
return fmt.Sprintf("custom preference tooltip is too long, max %d characters, is %d characters", FieldEntryMaxLength, common.StringLength(&c.Tooltip))
}
if c.Size != PreferenceSizeLarge && c.Size != PreferenceSizeNormal && c.Size != PreferenceSizeSmall {
return fmt.Sprintf("custom preference size %q is invalid", string(c.Size))
}
return ""
}
type PreferenceSize string
const (
PreferenceSizeLarge PreferenceSize = "large"
PreferenceSizeNormal PreferenceSize = "normal"
PreferenceSizeSmall PreferenceSize = "small"
)
func (u User) NumProviders() (numProviders int) { func (u User) NumProviders() (numProviders int) {
if u.Discord != nil { if u.Discord != nil {
numProviders++ numProviders++
@ -456,8 +498,9 @@ func (db *DB) UpdateUser(
memberTitle *string, listPrivate *bool, memberTitle *string, listPrivate *bool,
links *[]string, links *[]string,
avatar *string, avatar *string,
customPreferences *CustomPreferences,
) (u User, err error) { ) (u User, err error) {
if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil { if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil && customPreferences == nil {
sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql() sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql()
if err != nil { if err != nil {
return u, errors.Wrap(err, "building sql") return u, errors.Wrap(err, "building sql")
@ -499,6 +542,9 @@ func (db *DB) UpdateUser(
if listPrivate != nil { if listPrivate != nil {
builder = builder.Set("list_private", *listPrivate) builder = builder.Set("list_private", *listPrivate)
} }
if customPreferences != nil {
builder = builder.Set("custom_preferences", *customPreferences)
}
if avatar != nil { if avatar != nil {
if *avatar == "" { if *avatar == "" {

1968
backend/icons/icons.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -103,15 +103,15 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
} }
} }
if err := validateSlicePtr("name", &cmr.Names); err != nil { if err := validateSlicePtr("name", &cmr.Names, u.CustomPreferences); err != nil {
return *err return *err
} }
if err := validateSlicePtr("pronoun", &cmr.Pronouns); err != nil { if err := validateSlicePtr("pronoun", &cmr.Pronouns, u.CustomPreferences); err != nil {
return *err return *err
} }
if err := validateSlicePtr("field", &cmr.Fields); err != nil { if err := validateSlicePtr("field", &cmr.Fields, u.CustomPreferences); err != nil {
return *err return *err
} }
@ -186,12 +186,12 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
} }
type validator interface { type validator interface {
Validate() string Validate(custom db.CustomPreferences) string
} }
// validateSlicePtr validates a slice of validators. // validateSlicePtr validates a slice of validators.
// If the slice is nil, a nil error is returned (assuming that the field is not required) // If the slice is nil, a nil error is returned (assuming that the field is not required)
func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError { func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError {
if slice == nil { if slice == nil {
return nil return nil
} }
@ -211,7 +211,7 @@ func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError {
// validate all fields // validate all fields
for i, pronouns := range *slice { for i, pronouns := range *slice {
if s := pronouns.Validate(); s != "" { if s := pronouns.Validate(custom); s != "" {
return &server.APIError{ return &server.APIError{
Code: server.ErrBadRequest, Code: server.ErrBadRequest,
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s), Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),

View file

@ -42,10 +42,11 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember boo
Fields: db.NotNull(fields), Fields: db.NotNull(fields),
User: PartialUser{ User: PartialUser{
ID: u.ID, ID: u.ID,
Username: u.Username, Username: u.Username,
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Avatar: u.Avatar, Avatar: u.Avatar,
CustomPreferences: u.CustomPreferences,
}, },
} }
@ -57,10 +58,11 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember boo
} }
type PartialUser struct { type PartialUser struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
Username string `json:"name"` Username string `json:"name"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Avatar *string `json:"avatar"` Avatar *string `json:"avatar"`
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
} }
func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error { func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {

View file

@ -41,6 +41,11 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrMemberNotFound} return server.APIError{Code: server.ErrMemberNotFound}
} }
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
m, err := s.DB.Member(ctx, id) m, err := s.DB.Member(ctx, id)
if err != nil { if err != nil {
if err == db.ErrMemberNotFound { if err == db.ErrMemberNotFound {
@ -148,15 +153,15 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
} }
} }
if err := validateSlicePtr("name", req.Names); err != nil { if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil {
return *err return *err
} }
if err := validateSlicePtr("pronoun", req.Pronouns); err != nil { if err := validateSlicePtr("pronoun", req.Pronouns, u.CustomPreferences); err != nil {
return *err return *err
} }
if err := validateSlicePtr("field", req.Fields); err != nil { if err := validateSlicePtr("field", req.Fields, u.CustomPreferences); err != nil {
return *err return *err
} }
@ -271,11 +276,6 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
return err return err
} }
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
// echo the updated member back on success // echo the updated member back on success
render.JSON(w, r, dbMemberToMember(u, m, fields, true)) render.JSON(w, r, dbMemberToMember(u, m, fields, true))
return nil return nil

View file

@ -12,17 +12,18 @@ import (
) )
type GetUserResponse struct { type GetUserResponse struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
Username string `json:"name"` Username string `json:"name"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Bio *string `json:"bio"` Bio *string `json:"bio"`
MemberTitle *string `json:"member_title"` MemberTitle *string `json:"member_title"`
Avatar *string `json:"avatar"` Avatar *string `json:"avatar"`
Links []string `json:"links"` Links []string `json:"links"`
Names []db.FieldEntry `json:"names"` Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"` Pronouns []db.PronounEntry `json:"pronouns"`
Members []PartialMember `json:"members"` Members []PartialMember `json:"members"`
Fields []db.Field `json:"fields"` Fields []db.Field `json:"fields"`
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
} }
type GetMeResponse struct { type GetMeResponse struct {
@ -59,16 +60,17 @@ type PartialMember struct {
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUserResponse { func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUserResponse {
resp := GetUserResponse{ resp := GetUserResponse{
ID: u.ID, ID: u.ID,
Username: u.Username, Username: u.Username,
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Bio: u.Bio, Bio: u.Bio,
MemberTitle: u.MemberTitle, MemberTitle: u.MemberTitle,
Avatar: u.Avatar, Avatar: u.Avatar,
Links: db.NotNull(u.Links), Links: db.NotNull(u.Links),
Names: db.NotNull(u.Names), Names: db.NotNull(u.Names),
Pronouns: db.NotNull(u.Pronouns), Pronouns: db.NotNull(u.Pronouns),
Fields: db.NotNull(fields), Fields: db.NotNull(fields),
CustomPreferences: u.CustomPreferences,
} }
resp.Members = make([]PartialMember, len(members)) resp.Members = make([]PartialMember, len(members))

View file

@ -10,19 +10,21 @@ import (
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/google/uuid"
) )
type PatchUserRequest struct { type PatchUserRequest struct {
Username *string `json:"username"` Username *string `json:"username"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Bio *string `json:"bio"` Bio *string `json:"bio"`
MemberTitle *string `json:"member_title"` MemberTitle *string `json:"member_title"`
Links *[]string `json:"links"` Links *[]string `json:"links"`
Names *[]db.FieldEntry `json:"names"` Names *[]db.FieldEntry `json:"names"`
Pronouns *[]db.PronounEntry `json:"pronouns"` Pronouns *[]db.PronounEntry `json:"pronouns"`
Fields *[]db.Field `json:"fields"` Fields *[]db.Field `json:"fields"`
Avatar *string `json:"avatar"` Avatar *string `json:"avatar"`
ListPrivate *bool `json:"list_private"` ListPrivate *bool `json:"list_private"`
CustomPreferences *db.CustomPreferences `json:"custom_preferences"`
} }
// patchUser parses a PatchUserRequest and updates the user with the given ID. // patchUser parses a PatchUserRequest and updates the user with the given ID.
@ -57,7 +59,8 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
req.Fields == nil && req.Fields == nil &&
req.Names == nil && req.Names == nil &&
req.Pronouns == nil && req.Pronouns == nil &&
req.Avatar == nil { req.Avatar == nil &&
req.CustomPreferences == nil {
return server.APIError{ return server.APIError{
Code: server.ErrBadRequest, Code: server.ErrBadRequest,
Details: "Data must not be empty", Details: "Data must not be empty",
@ -103,18 +106,35 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
} }
} }
if err := validateSlicePtr("name", req.Names); err != nil { if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil {
return *err return *err
} }
if err := validateSlicePtr("pronoun", req.Pronouns); err != nil { if err := validateSlicePtr("pronoun", req.Pronouns, u.CustomPreferences); err != nil {
return *err return *err
} }
if err := validateSlicePtr("field", req.Fields); err != nil { if err := validateSlicePtr("field", req.Fields, u.CustomPreferences); err != nil {
return *err return *err
} }
// validate custom preferences
if req.CustomPreferences != nil {
if count := len(*req.CustomPreferences); count > db.MaxFields {
return server.APIError{Code: server.ErrBadRequest, Details: fmt.Sprintf("Too many custom preferences (max %d, current %d)", db.MaxFields, count)}
}
for k, v := range *req.CustomPreferences {
_, err := uuid.Parse(k)
if err != nil {
return server.APIError{Code: server.ErrBadRequest, Details: "One or more custom preference IDs is not a UUID."}
}
if s := v.Validate(); s != "" {
return server.APIError{Code: server.ErrBadRequest, Details: s}
}
}
}
// update avatar // update avatar
var avatarHash *string = nil var avatarHash *string = nil
if req.Avatar != nil { if req.Avatar != nil {
@ -186,7 +206,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
} }
} }
u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash) u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.CustomPreferences)
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate { if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
log.Errorf("updating user: %v", err) log.Errorf("updating user: %v", err)
return err return err
@ -263,12 +283,12 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
} }
type validator interface { type validator interface {
Validate() string Validate(custom db.CustomPreferences) string
} }
// validateSlicePtr validates a slice of validators. // validateSlicePtr validates a slice of validators.
// If the slice is nil, a nil error is returned (assuming that the field is not required) // If the slice is nil, a nil error is returned (assuming that the field is not required)
func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError { func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError {
if slice == nil { if slice == nil {
return nil return nil
} }
@ -288,7 +308,7 @@ func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError {
// validate all fields // validate all fields
for i, pronouns := range *slice { for i, pronouns := range *slice {
if s := pronouns.Validate(); s != "" { if s := pronouns.Validate(custom); s != "" {
return &server.APIError{ return &server.APIError{
Code: server.ErrBadRequest, Code: server.ErrBadRequest,
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s), Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),

44
frontend/icons.js Normal file
View file

@ -0,0 +1,44 @@
// This script regenerates the list of icons for the frontend (frontend/src/icons.json)
// and the backend (backend/icons/icons.go) from the currently installed version of Bootstrap Icons.
// Run with `pnpm node icons.js` in the frontend directory.
import { writeFileSync } from "fs";
import icons from "bootstrap-icons/font/bootstrap-icons.json" assert { type: "json" };
const keys = Object.keys(icons);
console.log(`Found ${keys.length} icons`);
const output = JSON.stringify(keys);
console.log(`Saving file as src/icons.ts`);
writeFileSync("src/icons.ts", `const icons = ${output};\nexport default icons;`);
const goCode1 = `// Generated code. DO NOT EDIT
package icons
var icons = [...]string{
`;
const goCode2 = `}
// IsValid returns true if the input is the name of a Bootstrap icon.
func IsValid(name string) bool {
for i := range icons {
if icons[i] == name {
return true
}
}
return false
}
`;
let goOutput = goCode1;
keys.forEach((element) => {
goOutput += ` "${element}",\n`;
});
goOutput += goCode2;
console.log("Writing Go code");
writeFileSync("../backend/icons/icons.go", goOutput);

2
frontend/src/icons.ts Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,48 @@
import { type CustomPreferences, PreferenceSize } from "./entities";
const defaultPreferences: CustomPreferences = {
favourite: {
icon: "heart-fill",
tooltip: "Favourite",
size: PreferenceSize.Large,
muted: false,
favourite: true,
},
okay: {
icon: "hand-thumbs-up",
tooltip: "Okay",
size: PreferenceSize.Normal,
muted: false,
favourite: false,
},
jokingly: {
icon: "emoji-laughing",
tooltip: "Jokingly",
size: PreferenceSize.Normal,
muted: false,
favourite: false,
},
friends_only: {
icon: "people",
tooltip: "Friends only",
size: PreferenceSize.Normal,
muted: false,
favourite: false,
},
avoid: {
icon: "hand-thumbs-down",
tooltip: "Avoid",
size: PreferenceSize.Small,
muted: true,
favourite: false,
},
missing: {
icon: "question-lg",
tooltip: "Unknown (missing)",
size: PreferenceSize.Normal,
muted: false,
favourite: false,
},
};
export default defaultPreferences;

View file

@ -16,6 +16,25 @@ export interface User {
pronouns: Pronoun[]; pronouns: Pronoun[];
members: PartialMember[]; members: PartialMember[];
fields: Field[]; fields: Field[];
custom_preferences: CustomPreferences;
}
export interface CustomPreferences {
[key: string]: CustomPreference;
}
export interface CustomPreference {
icon: string;
tooltip: string;
size: PreferenceSize;
muted: boolean;
favourite: boolean;
}
export enum PreferenceSize {
Large = "large",
Normal = "normal",
Small = "small",
} }
export interface MeUser extends User { export interface MeUser extends User {
@ -39,13 +58,13 @@ export interface Field {
export interface FieldEntry { export interface FieldEntry {
value: string; value: string;
status: WordStatus; status: string;
} }
export interface Pronoun { export interface Pronoun {
pronouns: string; pronouns: string;
display_text: string | null; display_text: string | null;
status: WordStatus; status: string;
} }
export enum WordStatus { export enum WordStatus {
@ -80,6 +99,7 @@ export interface MemberPartialUser {
name: string; name: string;
display_name: string | null; display_name: string | null;
avatar: string | null; avatar: string | null;
custom_preferences: CustomPreferences;
} }
export interface Invite { export interface Invite {

View file

@ -1,16 +1,17 @@
<script lang="ts"> <script lang="ts">
import type { Field } from "$lib/api/entities"; import type { CustomPreferences, Field } from "$lib/api/entities";
import StatusLine from "./StatusLine.svelte"; import StatusLine from "./StatusLine.svelte";
export let field: Field; export let field: Field;
export let preferences: CustomPreferences;
</script> </script>
<div> <div>
<h3>{field.name}</h3> <h3>{field.name}</h3>
<ul class="list-unstyled fs-5"> <ul class="list-unstyled fs-5">
{#each field.entries as entry} {#each field.entries as entry}
<li><StatusLine status={entry.status}>{entry.value}</StatusLine></li> <li><StatusLine {preferences} status={entry.status}>{entry.value}</StatusLine></li>
{/each} {/each}
</ul> </ul>
</div> </div>

View file

@ -4,8 +4,8 @@
export let icon: string; export let icon: string;
export let color: "primary" | "secondary" | "success" | "danger"; export let color: "primary" | "secondary" | "success" | "danger";
export let tooltip: string; export let tooltip: string;
export let active: boolean = false; export let active = false;
export let disabled: boolean = false; export let disabled = false;
export let type: string | undefined = undefined; export let type: string | undefined = undefined;
export let id: string | undefined = undefined; export let id: string | undefined = undefined;

View file

@ -1,53 +1,24 @@
<script lang="ts"> <script lang="ts">
import { Icon, Tooltip } from "sveltestrap"; import { Icon, Tooltip } from "sveltestrap";
import { WordStatus } from "$lib/api/entities"; import type { CustomPreference, CustomPreferences } from "$lib/api/entities";
import defaultPreferences from "$lib/api/default_preferences";
export let status: WordStatus; export let preferences: CustomPreferences;
export let status: string;
export let className: string | null = null; export let className: string | null = null;
const iconFor = (wordStatus: WordStatus) => { let mergedPreferences: CustomPreferences;
switch (wordStatus) { $: mergedPreferences = Object.assign(defaultPreferences, preferences);
case WordStatus.Favourite:
return "heart-fill";
case WordStatus.Okay:
return "hand-thumbs-up";
case WordStatus.Jokingly:
return "emoji-laughing";
case WordStatus.FriendsOnly:
return "people";
case WordStatus.Avoid:
return "hand-thumbs-down";
default:
return "hand-thumbs-up";
}
};
const textFor = (wordStatus: WordStatus) => { let currentPreference: CustomPreference;
switch (wordStatus) { $: currentPreference =
case WordStatus.Favourite: status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing;
return "Favourite";
case WordStatus.Okay:
return "Okay";
case WordStatus.Jokingly:
return "Jokingly";
case WordStatus.FriendsOnly:
return "Friends only";
case WordStatus.Avoid:
return "Avoid";
default:
return "Okay";
}
};
let statusIcon: string;
$: statusIcon = iconFor(status);
let statusText: string;
$: statusText = textFor(status);
let iconElement: HTMLElement; let iconElement: HTMLElement;
</script> </script>
<span bind:this={iconElement} tabindex={0}><Icon name={statusIcon} class={className} /></span> <span bind:this={iconElement} tabindex={0}
<Tooltip target={iconElement} placement="top">{statusText}</Tooltip> ><Icon name={currentPreference.icon} class={className} /></span
>
<Tooltip target={iconElement} placement="top">{currentPreference.tooltip}</Tooltip>

View file

@ -1,14 +1,44 @@
<script lang="ts"> <script lang="ts">
import { WordStatus } from "$lib/api/entities"; import { PreferenceSize } from "$lib/api/entities";
import StatusIcon from "$lib/components/StatusIcon.svelte"; import StatusIcon from "$lib/components/StatusIcon.svelte";
export let status: WordStatus; import type { CustomPreference, CustomPreferences } from "$lib/api/entities";
import defaultPreferences from "$lib/api/default_preferences";
export let preferences: CustomPreferences;
export let status: string;
let mergedPreferences: CustomPreferences;
$: mergedPreferences = Object.assign(defaultPreferences, preferences);
let currentPreference: CustomPreference;
$: currentPreference =
status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing;
let classes: string;
$: classes = setClasses(currentPreference);
const setClasses = (pref: CustomPreference) => {
let classes = "";
if (pref.muted) {
classes += "text-muted ";
}
switch (pref.size) {
case PreferenceSize.Large:
classes += "fs-5";
break;
case PreferenceSize.Normal:
break;
case PreferenceSize.Small:
classes += "fs-6";
}
return classes.trim();
};
</script> </script>
{#if status === WordStatus.Favourite} {#if currentPreference.size === PreferenceSize.Large}
<strong class="fs-5"><StatusIcon {status} /> <slot /></strong> <strong class={classes}><StatusIcon {preferences} {status} /> <slot /></strong>
{:else if status === WordStatus.Avoid}
<span class="fs-6 text-muted"><StatusIcon {status} /> <slot /></span>
{:else} {:else}
<StatusIcon {status} /> <slot /> <span class={classes}><StatusIcon {preferences} {status} /> <slot /></span>
{/if} {/if}

View file

@ -6,7 +6,7 @@ import type { MeUser } from "./api/entities";
const initialUserValue = null; const initialUserValue = null;
export const userStore = writable<MeUser | null>(initialUserValue); export const userStore = writable<MeUser | null>(initialUserValue);
let defaultThemeValue = "dark"; const defaultThemeValue = "dark";
const initialThemeValue = browser const initialThemeValue = browser
? window.localStorage.getItem("pronouns-theme") ?? defaultThemeValue ? window.localStorage.getItem("pronouns-theme") ?? defaultThemeValue
: defaultThemeValue; : defaultThemeValue;

View file

@ -20,10 +20,12 @@
MAX_MEMBERS, MAX_MEMBERS,
pronounDisplay, pronounDisplay,
userAvatars, userAvatars,
WordStatus,
type APIError, type APIError,
type Member, type Member,
type PartialMember, type PartialMember,
type CustomPreferences,
type FieldEntry,
type Pronoun,
} from "$lib/api/entities"; } from "$lib/api/entities";
import { PUBLIC_BASE_URL } from "$env/static/public"; import { PUBLIC_BASE_URL } from "$env/static/public";
import { apiFetchClient } from "$lib/api/fetch"; import { apiFetchClient } from "$lib/api/fetch";
@ -34,13 +36,14 @@
import ProfileLink from "./ProfileLink.svelte"; import ProfileLink from "./ProfileLink.svelte";
import { memberNameRegex } from "$lib/api/regex"; import { memberNameRegex } from "$lib/api/regex";
import StatusLine from "$lib/components/StatusLine.svelte"; import StatusLine from "$lib/components/StatusLine.svelte";
import defaultPreferences from "$lib/api/default_preferences";
export let data: PageData; export let data: PageData;
let bio: string | null; let bio: string | null;
$: bio = renderMarkdown(data.bio); $: bio = renderMarkdown(data.bio);
let memberPage: number = 0; let memberPage = 0;
let memberSlice: PartialMember[] = []; let memberSlice: PartialMember[] = [];
$: memberSlice = data.members.slice(memberPage * 20, (memberPage + 1) * 20); $: memberSlice = data.members.slice(memberPage * 20, (memberPage + 1) * 20);
const totalPages = Math.ceil(data.members.length / 20); const totalPages = Math.ceil(data.members.length / 20);
@ -84,8 +87,17 @@
} }
}; };
const favNames = data.names.filter((entry) => entry.status === WordStatus.Favourite); let mergedPreferences: CustomPreferences;
const favPronouns = data.pronouns.filter((entry) => entry.status === WordStatus.Favourite); $: mergedPreferences = Object.assign(defaultPreferences, data.custom_preferences);
let favNames: FieldEntry[];
$: favNames = data.names.filter(
(entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite,
);
let favPronouns: Pronoun[];
$: favPronouns = data.pronouns.filter(
(entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite,
);
let profileEmpty = false; let profileEmpty = false;
$: profileEmpty = $: profileEmpty =
@ -146,7 +158,11 @@
<h3>Names</h3> <h3>Names</h3>
<ul class="list-unstyled fs-5"> <ul class="list-unstyled fs-5">
{#each data.names as name} {#each data.names as name}
<li><StatusLine status={name.status}>{name.value}</StatusLine></li> <li>
<StatusLine preferences={data.custom_preferences} status={name.status}
>{name.value}</StatusLine
>
</li>
{/each} {/each}
</ul> </ul>
</div> </div>
@ -156,14 +172,18 @@
<h3>Pronouns</h3> <h3>Pronouns</h3>
<ul class="list-unstyled fs-5"> <ul class="list-unstyled fs-5">
{#each data.pronouns as pronouns} {#each data.pronouns as pronouns}
<li><StatusLine status={pronouns.status}><PronounLink {pronouns} /></StatusLine></li> <li>
<StatusLine preferences={data.custom_preferences} status={pronouns.status}
><PronounLink {pronouns} /></StatusLine
>
</li>
{/each} {/each}
</ul> </ul>
</div> </div>
{/if} {/if}
{#each data.fields as field} {#each data.fields as field}
<div class="col"> <div class="col">
<FieldCard {field} /> <FieldCard preferences={data.custom_preferences} {field} />
</div> </div>
{/each} {/each}
</div> </div>

View file

@ -2,25 +2,40 @@
import FieldCard from "$lib/components/FieldCard.svelte"; import FieldCard from "$lib/components/FieldCard.svelte";
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import StatusIcon from "$lib/components/StatusIcon.svelte";
import PronounLink from "$lib/components/PronounLink.svelte"; import PronounLink from "$lib/components/PronounLink.svelte";
import FallbackImage from "$lib/components/FallbackImage.svelte"; import FallbackImage from "$lib/components/FallbackImage.svelte";
import { Alert, Button, Icon } from "sveltestrap"; import { Alert, Button, Icon } from "sveltestrap";
import { memberAvatars, pronounDisplay, WordStatus } from "$lib/api/entities"; import {
memberAvatars,
pronounDisplay,
type CustomPreferences,
type FieldEntry,
type Pronoun,
} from "$lib/api/entities";
import { PUBLIC_BASE_URL } from "$env/static/public"; import { PUBLIC_BASE_URL } from "$env/static/public";
import { userStore } from "$lib/store"; import { userStore } from "$lib/store";
import { renderMarkdown } from "$lib/utils"; import { renderMarkdown } from "$lib/utils";
import ReportButton from "../ReportButton.svelte"; import ReportButton from "../ReportButton.svelte";
import ProfileLink from "../ProfileLink.svelte"; import ProfileLink from "../ProfileLink.svelte";
import StatusLine from "$lib/components/StatusLine.svelte"; import StatusLine from "$lib/components/StatusLine.svelte";
import defaultPreferences from "$lib/api/default_preferences";
export let data: PageData; export let data: PageData;
let bio: string | null; let bio: string | null;
$: bio = renderMarkdown(data.bio); $: bio = renderMarkdown(data.bio);
const favNames = data.names.filter((entry) => entry.status === WordStatus.Favourite); let mergedPreferences: CustomPreferences;
const favPronouns = data.pronouns.filter((entry) => entry.status === WordStatus.Favourite); $: mergedPreferences = Object.assign(defaultPreferences, data.user.custom_preferences);
let favNames: FieldEntry[];
$: favNames = data.names.filter(
(entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite,
);
let favPronouns: Pronoun[];
$: favPronouns = data.pronouns.filter(
(entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite,
);
let profileEmpty = false; let profileEmpty = false;
$: profileEmpty = $: profileEmpty =
@ -81,7 +96,11 @@
<h3>Names</h3> <h3>Names</h3>
<ul class="list-unstyled fs-5"> <ul class="list-unstyled fs-5">
{#each data.names as name} {#each data.names as name}
<li><StatusLine status={name.status}>{name.value}</StatusLine></li> <li>
<StatusLine preferences={data.user.custom_preferences} status={name.status}
>{name.value}</StatusLine
>
</li>
{/each} {/each}
</ul> </ul>
</div> </div>
@ -91,14 +110,18 @@
<h3>Pronouns</h3> <h3>Pronouns</h3>
<ul class="list-unstyled fs-5"> <ul class="list-unstyled fs-5">
{#each data.pronouns as pronouns} {#each data.pronouns as pronouns}
<li><StatusLine status={pronouns.status}><PronounLink {pronouns} /></StatusLine></li> <li>
<StatusLine preferences={data.user.custom_preferences} status={pronouns.status}
><PronounLink {pronouns} /></StatusLine
>
</li>
{/each} {/each}
</ul> </ul>
</div> </div>
{/if} {/if}
{#each data.fields as field} {#each data.fields as field}
<div class="col"> <div class="col">
<FieldCard {field} /> <FieldCard preferences={data.user.custom_preferences} {field} />
</div> </div>
{/each} {/each}
</div> </div>

View file

@ -1,19 +1,20 @@
<script lang="ts"> <script lang="ts">
import { WordStatus, type Field } from "$lib/api/entities"; import type { Field, CustomPreferences } from "$lib/api/entities";
import IconButton from "$lib/components/IconButton.svelte"; import IconButton from "$lib/components/IconButton.svelte";
import { Button, Input, InputGroup } from "sveltestrap"; import { Button, Input, InputGroup } from "sveltestrap";
import FieldEntry from "./FieldEntry.svelte"; import FieldEntry from "./FieldEntry.svelte";
export let field: Field; export let field: Field;
export let preferences: CustomPreferences;
export let deleteField: () => void; export let deleteField: () => void;
export let moveField: (up: boolean) => void; export let moveField: (up: boolean) => void;
let newEntry: string = ""; let newEntry = "";
const addEntry = (event: Event) => { const addEntry = (event: Event) => {
event.preventDefault(); event.preventDefault();
field.entries = [...field.entries, { value: newEntry, status: WordStatus.Okay }]; field.entries = [...field.entries, { value: newEntry, status: "missing" }];
newEntry = ""; newEntry = "";
}; };
@ -57,6 +58,7 @@
<FieldEntry <FieldEntry
bind:value={field.entries[index].value} bind:value={field.entries[index].value}
bind:status={field.entries[index].status} bind:status={field.entries[index].status}
{preferences}
moveUp={() => moveEntry(index, true)} moveUp={() => moveEntry(index, true)}
moveDown={() => moveEntry(index, false)} moveDown={() => moveEntry(index, false)}
remove={() => removeEntry(index)} remove={() => removeEntry(index)}

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { WordStatus } from "$lib/api/entities"; import defaultPreferences from "$lib/api/default_preferences";
import type { CustomPreference, CustomPreferences } from "$lib/api/entities";
import IconButton from "$lib/components/IconButton.svelte"; import IconButton from "$lib/components/IconButton.svelte";
import { import {
ButtonDropdown, ButtonDropdown,
@ -11,46 +12,23 @@
} from "sveltestrap"; } from "sveltestrap";
export let value: string; export let value: string;
export let status: WordStatus; export let status: string;
export let preferences: CustomPreferences;
export let moveUp: () => void; export let moveUp: () => void;
export let moveDown: () => void; export let moveDown: () => void;
export let remove: () => void; export let remove: () => void;
let buttonElement: HTMLElement; let buttonElement: HTMLElement;
const iconFor = (wordStatus: WordStatus) => { let mergedPreferences: CustomPreferences;
switch (wordStatus) { $: mergedPreferences = Object.assign(defaultPreferences, preferences);
case WordStatus.Favourite:
return "heart-fill";
case WordStatus.Okay:
return "hand-thumbs-up";
case WordStatus.Jokingly:
return "emoji-laughing";
case WordStatus.FriendsOnly:
return "people";
case WordStatus.Avoid:
return "hand-thumbs-down";
default:
return "hand-thumbs-up";
}
};
const textFor = (wordStatus: WordStatus) => { let currentPreference: CustomPreference;
switch (wordStatus) { $: currentPreference =
case WordStatus.Favourite: status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing;
return "Favourite";
case WordStatus.Okay: let preferenceIds: string[];
return "Okay"; $: preferenceIds = Object.keys(mergedPreferences).filter((s) => s !== "missing");
case WordStatus.Jokingly:
return "Jokingly";
case WordStatus.FriendsOnly:
return "Friends only";
case WordStatus.Avoid:
return "Avoid";
default:
return "Okay";
}
};
</script> </script>
<div class="input-group m-1"> <div class="input-group m-1">
@ -58,30 +36,17 @@
<IconButton icon="chevron-down" color="secondary" tooltip="Move name down" click={moveDown} /> <IconButton icon="chevron-down" color="secondary" tooltip="Move name down" click={moveDown} />
<input type="text" class="form-control" bind:value /> <input type="text" class="form-control" bind:value />
<ButtonDropdown> <ButtonDropdown>
<Tooltip target={buttonElement} placement="top">{textFor(status)}</Tooltip> <Tooltip target={buttonElement} placement="top">{currentPreference.tooltip}</Tooltip>
<DropdownToggle color="secondary" caret bind:inner={buttonElement}> <DropdownToggle color="secondary" caret bind:inner={buttonElement}>
<Icon name={iconFor(status)} /> <Icon name={currentPreference.icon} />
</DropdownToggle> </DropdownToggle>
<DropdownMenu> <DropdownMenu>
<DropdownItem {#each preferenceIds as id}
on:click={() => (status = WordStatus.Favourite)} <DropdownItem on:click={() => (status = id)} active={status === id}>
active={status === WordStatus.Favourite}>Favourite</DropdownItem <Icon name={mergedPreferences[id].icon} aria-hidden />
> {mergedPreferences[id].tooltip}
<DropdownItem on:click={() => (status = WordStatus.Okay)} active={status === WordStatus.Okay} </DropdownItem>
>Okay</DropdownItem {/each}
>
<DropdownItem
on:click={() => (status = WordStatus.Jokingly)}
active={status === WordStatus.Jokingly}>Jokingly</DropdownItem
>
<DropdownItem
on:click={() => (status = WordStatus.FriendsOnly)}
active={status === WordStatus.FriendsOnly}>Friends only</DropdownItem
>
<DropdownItem
on:click={() => (status = WordStatus.Avoid)}
active={status === WordStatus.Avoid}>Avoid</DropdownItem
>
</DropdownMenu> </DropdownMenu>
</ButtonDropdown> </ButtonDropdown>
<IconButton color="danger" icon="trash3" tooltip="Remove name" click={remove} /> <IconButton color="danger" icon="trash3" tooltip="Remove name" click={remove} />

View file

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { WordStatus, type Pronoun, pronounDisplay } from "$lib/api/entities"; import defaultPreferences from "$lib/api/default_preferences";
import type { Pronoun, CustomPreference, CustomPreferences } from "$lib/api/entities";
import IconButton from "$lib/components/IconButton.svelte"; import IconButton from "$lib/components/IconButton.svelte";
import { import {
Button,
ButtonDropdown, ButtonDropdown,
Collapse, Collapse,
DropdownItem, DropdownItem,
@ -15,6 +15,7 @@
} from "sveltestrap"; } from "sveltestrap";
export let pronoun: Pronoun; export let pronoun: Pronoun;
export let preferences: CustomPreferences;
export let moveUp: () => void; export let moveUp: () => void;
export let moveDown: () => void; export let moveDown: () => void;
export let remove: () => void; export let remove: () => void;
@ -23,39 +24,17 @@
let displayOpen = false; let displayOpen = false;
const toggleDisplay = () => (displayOpen = !displayOpen); const toggleDisplay = () => (displayOpen = !displayOpen);
const iconFor = (wordStatus: WordStatus) => { let mergedPreferences: CustomPreferences;
switch (wordStatus) { $: mergedPreferences = Object.assign(defaultPreferences, preferences);
case WordStatus.Favourite:
return "heart-fill";
case WordStatus.Okay:
return "hand-thumbs-up";
case WordStatus.Jokingly:
return "emoji-laughing";
case WordStatus.FriendsOnly:
return "people";
case WordStatus.Avoid:
return "hand-thumbs-down";
default:
return "hand-thumbs-up";
}
};
const textFor = (wordStatus: WordStatus) => { let currentPreference: CustomPreference;
switch (wordStatus) { $: currentPreference =
case WordStatus.Favourite: pronoun.status in mergedPreferences
return "Favourite"; ? mergedPreferences[pronoun.status]
case WordStatus.Okay: : defaultPreferences.missing;
return "Okay";
case WordStatus.Jokingly: let preferenceIds: string[];
return "Jokingly"; $: preferenceIds = Object.keys(mergedPreferences).filter((s) => s !== "missing");
case WordStatus.FriendsOnly:
return "Friends only";
case WordStatus.Avoid:
return "Avoid";
default:
return "Okay";
}
};
</script> </script>
<div class="input-group m-1"> <div class="input-group m-1">
@ -75,31 +54,17 @@
click={toggleDisplay} click={toggleDisplay}
/> />
<ButtonDropdown> <ButtonDropdown>
<Tooltip target={buttonElement} placement="top">{textFor(pronoun.status)}</Tooltip> <Tooltip target={buttonElement} placement="top">{currentPreference.tooltip}</Tooltip>
<DropdownToggle color="secondary" caret bind:inner={buttonElement}> <DropdownToggle color="secondary" caret bind:inner={buttonElement}>
<Icon name={iconFor(pronoun.status)} /> <Icon name={currentPreference.icon} />
</DropdownToggle> </DropdownToggle>
<DropdownMenu> <DropdownMenu>
<DropdownItem {#each preferenceIds as id}
on:click={() => (pronoun.status = WordStatus.Favourite)} <DropdownItem on:click={() => (pronoun.status = id)} active={pronoun.status === id}>
active={pronoun.status === WordStatus.Favourite}>Favourite</DropdownItem <Icon name={mergedPreferences[id].icon} aria-hidden />
> {mergedPreferences[id].tooltip}
<DropdownItem </DropdownItem>
on:click={() => (pronoun.status = WordStatus.Okay)} {/each}
active={pronoun.status === WordStatus.Okay}>Okay</DropdownItem
>
<DropdownItem
on:click={() => (pronoun.status = WordStatus.Jokingly)}
active={pronoun.status === WordStatus.Jokingly}>Jokingly</DropdownItem
>
<DropdownItem
on:click={() => (pronoun.status = WordStatus.FriendsOnly)}
active={pronoun.status === WordStatus.FriendsOnly}>Friends only</DropdownItem
>
<DropdownItem
on:click={() => (pronoun.status = WordStatus.Avoid)}
active={pronoun.status === WordStatus.Avoid}>Avoid</DropdownItem
>
</DropdownMenu> </DropdownMenu>
</ButtonDropdown> </ButtonDropdown>
<IconButton color="danger" icon="trash3" tooltip="Remove pronouns" click={remove} /> <IconButton color="danger" icon="trash3" tooltip="Remove pronouns" click={remove} />

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { WordStatus } from "$lib/api/entities"; import defaultPreferences from "$lib/api/default_preferences";
import type { CustomPreference, CustomPreferences } from "$lib/api/entities";
import IconButton from "$lib/components/IconButton.svelte"; import IconButton from "$lib/components/IconButton.svelte";
import { import {
ButtonDropdown, ButtonDropdown,
@ -11,46 +12,23 @@
} from "sveltestrap"; } from "sveltestrap";
export let value: string; export let value: string;
export let status: WordStatus; export let status: string;
export let preferences: CustomPreferences;
export let moveUp: () => void; export let moveUp: () => void;
export let moveDown: () => void; export let moveDown: () => void;
export let remove: () => void; export let remove: () => void;
let buttonElement: HTMLElement; let buttonElement: HTMLElement;
const iconFor = (wordStatus: WordStatus) => { let mergedPreferences: CustomPreferences;
switch (wordStatus) { $: mergedPreferences = Object.assign(defaultPreferences, preferences);
case WordStatus.Favourite:
return "heart-fill";
case WordStatus.Okay:
return "hand-thumbs-up";
case WordStatus.Jokingly:
return "emoji-laughing";
case WordStatus.FriendsOnly:
return "people";
case WordStatus.Avoid:
return "hand-thumbs-down";
default:
return "hand-thumbs-up";
}
};
const textFor = (wordStatus: WordStatus) => { let currentPreference: CustomPreference;
switch (wordStatus) { $: currentPreference =
case WordStatus.Favourite: status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing;
return "Favourite";
case WordStatus.Okay: let preferenceIds: string[];
return "Okay"; $: preferenceIds = Object.keys(mergedPreferences).filter((s) => s !== "missing");
case WordStatus.Jokingly:
return "Jokingly";
case WordStatus.FriendsOnly:
return "Friends only";
case WordStatus.Avoid:
return "Avoid";
default:
return "Okay";
}
};
</script> </script>
<div class="input-group m-1"> <div class="input-group m-1">
@ -58,30 +36,17 @@
<IconButton icon="chevron-down" color="secondary" tooltip="Move entry down" click={moveDown} /> <IconButton icon="chevron-down" color="secondary" tooltip="Move entry down" click={moveDown} />
<input type="text" class="form-control" bind:value /> <input type="text" class="form-control" bind:value />
<ButtonDropdown> <ButtonDropdown>
<Tooltip target={buttonElement} placement="top">{textFor(status)}</Tooltip> <Tooltip target={buttonElement} placement="top">{currentPreference.tooltip}</Tooltip>
<DropdownToggle color="secondary" caret bind:inner={buttonElement}> <DropdownToggle color="secondary" caret bind:inner={buttonElement}>
<Icon name={iconFor(status)} /> <Icon name={currentPreference.icon} />
</DropdownToggle> </DropdownToggle>
<DropdownMenu> <DropdownMenu>
<DropdownItem {#each preferenceIds as id}
on:click={() => (status = WordStatus.Favourite)} <DropdownItem on:click={() => (status = id)} active={status === id}>
active={status === WordStatus.Favourite}>Favourite</DropdownItem <Icon name={mergedPreferences[id].icon} aria-hidden />
> {mergedPreferences[id].tooltip}
<DropdownItem on:click={() => (status = WordStatus.Okay)} active={status === WordStatus.Okay} </DropdownItem>
>Okay</DropdownItem {/each}
>
<DropdownItem
on:click={() => (status = WordStatus.Jokingly)}
active={status === WordStatus.Jokingly}>Jokingly</DropdownItem
>
<DropdownItem
on:click={() => (status = WordStatus.FriendsOnly)}
active={status === WordStatus.FriendsOnly}>Friends only</DropdownItem
>
<DropdownItem
on:click={() => (status = WordStatus.Avoid)}
active={status === WordStatus.Avoid}>Avoid</DropdownItem
>
</DropdownMenu> </DropdownMenu>
</ButtonDropdown> </ButtonDropdown>
<IconButton color="danger" icon="trash3" tooltip="Remove entry" click={remove} /> <IconButton color="danger" icon="trash3" tooltip="Remove entry" click={remove} />

View file

@ -153,10 +153,9 @@
if (list[0].size > MAX_AVATAR_BYTES) { if (list[0].size > MAX_AVATAR_BYTES) {
addToast({ addToast({
header: "Avatar too large", header: "Avatar too large",
body: body: `This avatar is too large, please resize it (maximum is ${prettyBytes(
`This avatar is too large, please resize it (maximum is ${prettyBytes( MAX_AVATAR_BYTES,
MAX_AVATAR_BYTES, )}, the file you tried to upload is ${prettyBytes(list[0].size)})`,
)}, the file you tried to upload is ${prettyBytes(list[0].size)})`,
}); });
return null; return null;
} }
@ -440,6 +439,7 @@
<EditableName <EditableName
bind:value={names[index].value} bind:value={names[index].value}
bind:status={names[index].status} bind:status={names[index].status}
preferences={data.user.custom_preferences}
moveUp={() => moveName(index, true)} moveUp={() => moveName(index, true)}
moveDown={() => moveName(index, false)} moveDown={() => moveName(index, false)}
remove={() => removeName(index)} remove={() => removeName(index)}
@ -479,6 +479,7 @@
{#each pronouns as _, index} {#each pronouns as _, index}
<EditablePronouns <EditablePronouns
bind:pronoun={pronouns[index]} bind:pronoun={pronouns[index]}
preferences={data.user.custom_preferences}
moveUp={() => movePronoun(index, true)} moveUp={() => movePronoun(index, true)}
moveDown={() => movePronoun(index, false)} moveDown={() => movePronoun(index, false)}
remove={() => removePronoun(index)} remove={() => removePronoun(index)}
@ -520,6 +521,7 @@
{#each fields as _, index} {#each fields as _, index}
<EditableField <EditableField
bind:field={fields[index]} bind:field={fields[index]}
preferences={data.user.custom_preferences}
deleteField={() => removeField(index)} deleteField={() => removeField(index)}
moveField={(up) => moveField(index, up)} moveField={(up) => moveField(index, up)}
/> />

View file

@ -8,6 +8,8 @@
type FieldEntry, type FieldEntry,
type MeUser, type MeUser,
type Pronoun, type Pronoun,
PreferenceSize,
type CustomPreferences,
} from "$lib/api/entities"; } from "$lib/api/entities";
import FallbackImage from "$lib/components/FallbackImage.svelte"; import FallbackImage from "$lib/components/FallbackImage.svelte";
import { userStore } from "$lib/store"; import { userStore } from "$lib/store";
@ -37,6 +39,7 @@
import { charCount, renderMarkdown } from "$lib/utils"; import { charCount, renderMarkdown } from "$lib/utils";
import MarkdownHelp from "../MarkdownHelp.svelte"; import MarkdownHelp from "../MarkdownHelp.svelte";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import CustomPreference from "./CustomPreference.svelte";
const MAX_AVATAR_BYTES = 1_000_000; const MAX_AVATAR_BYTES = 1_000_000;
@ -52,6 +55,7 @@
let pronouns: Pronoun[] = window.structuredClone(data.user.pronouns); let pronouns: Pronoun[] = window.structuredClone(data.user.pronouns);
let fields: Field[] = window.structuredClone(data.user.fields); let fields: Field[] = window.structuredClone(data.user.fields);
let list_private = data.user.list_private; let list_private = data.user.list_private;
let custom_preferences = window.structuredClone(data.user.custom_preferences);
let avatar: string | null; let avatar: string | null;
let avatar_files: FileList | null; let avatar_files: FileList | null;
@ -60,6 +64,9 @@
let newPronouns = ""; let newPronouns = "";
let newLink = ""; let newLink = "";
let preferenceIds: string[];
$: preferenceIds = Object.keys(custom_preferences);
let modified = false; let modified = false;
$: modified = isModified( $: modified = isModified(
@ -73,6 +80,7 @@
avatar, avatar,
member_title, member_title,
list_private, list_private,
custom_preferences,
); );
$: getAvatar(avatar_files).then((b64) => (avatar = b64)); $: getAvatar(avatar_files).then((b64) => (avatar = b64));
@ -87,6 +95,7 @@
avatar: string | null, avatar: string | null,
member_title: string, member_title: string,
list_private: boolean, list_private: boolean,
custom_preferences: CustomPreferences,
) => { ) => {
if (bio !== (user.bio || "")) return true; if (bio !== (user.bio || "")) return true;
if (display_name !== (user.display_name || "")) return true; if (display_name !== (user.display_name || "")) return true;
@ -95,6 +104,7 @@
if (!fieldsEqual(fields, user.fields)) return true; if (!fieldsEqual(fields, user.fields)) return true;
if (!namesEqual(names, user.names)) return true; if (!namesEqual(names, user.names)) return true;
if (!pronounsEqual(pronouns, user.pronouns)) return true; if (!pronounsEqual(pronouns, user.pronouns)) return true;
if (!customPreferencesEqual(custom_preferences, user.custom_preferences)) return true;
if (avatar !== null) return true; if (avatar !== null) return true;
if (list_private !== user.list_private) return true; if (list_private !== user.list_private) return true;
@ -136,6 +146,21 @@
return arr1.every((_, i) => arr1[i] === arr2[i]); return arr1.every((_, i) => arr1[i] === arr2[i]);
}; };
const customPreferencesEqual = (obj1: CustomPreferences, obj2: CustomPreferences) => {
return Object.keys(obj1)
.map((key) => {
if (!(key in obj2)) return false;
return (
obj1[key].icon === obj2[key].icon &&
obj1[key].tooltip === obj2[key].tooltip &&
obj1[key].favourite === obj2[key].favourite &&
obj1[key].muted === obj2[key].muted &&
obj1[key].size === obj2[key].size
);
})
.every((entry) => entry);
};
const getAvatar = async (list: FileList | null) => { const getAvatar = async (list: FileList | null) => {
if (!list || list.length === 0) return null; if (!list || list.length === 0) return null;
if (list[0].size > MAX_AVATAR_BYTES) { if (list[0].size > MAX_AVATAR_BYTES) {
@ -226,6 +251,19 @@
newLink = ""; newLink = "";
}; };
const addPreference = () => {
const id = crypto.randomUUID();
custom_preferences[id] = {
icon: "question",
tooltip: "New preference",
size: PreferenceSize.Normal,
muted: false,
favourite: false,
};
custom_preferences = custom_preferences;
};
const removeName = (index: number) => { const removeName = (index: number) => {
names.splice(index, 1); names.splice(index, 1);
names = [...names]; names = [...names];
@ -246,6 +284,11 @@
fields = [...fields]; fields = [...fields];
}; };
const removePreference = (id: string) => {
delete custom_preferences[id];
custom_preferences = custom_preferences;
};
const updateUser = async () => { const updateUser = async () => {
const toastId = addToast({ const toastId = addToast({
header: "Saving changes", header: "Saving changes",
@ -264,6 +307,7 @@
fields, fields,
member_title, member_title,
list_private, list_private,
custom_preferences,
}); });
data.user = resp; data.user = resp;
@ -367,6 +411,7 @@
<EditableName <EditableName
bind:value={names[index].value} bind:value={names[index].value}
bind:status={names[index].status} bind:status={names[index].status}
preferences={data.user.custom_preferences}
moveUp={() => moveName(index, true)} moveUp={() => moveName(index, true)}
moveDown={() => moveName(index, false)} moveDown={() => moveName(index, false)}
remove={() => removeName(index)} remove={() => removeName(index)}
@ -447,6 +492,7 @@
{#each fields as _, index} {#each fields as _, index}
<EditableField <EditableField
bind:field={fields[index]} bind:field={fields[index]}
preferences={data.user.custom_preferences}
deleteField={() => removeField(index)} deleteField={() => removeField(index)}
moveField={(up) => moveField(index, up)} moveField={(up) => moveField(index, up)}
/> />
@ -478,7 +524,7 @@
</form> </form>
</div> </div>
</TabPane> </TabPane>
<TabPane tabId="other" tab="Other"> <TabPane tabId="other" tab="Preferences & other">
<div class="row mt-3"> <div class="row mt-3">
<div class="col-md"> <div class="col-md">
<FormGroup floating label={'"Members" header text'}> <FormGroup floating label={'"Members" header text'}>
@ -510,5 +556,18 @@
</p> </p>
</div> </div>
</div> </div>
<div>
<h3>
Preferences <Button on:click={addPreference} color="success"
><Icon name="plus" aria-hidden /> Add new</Button
>
</h3>
{#each preferenceIds as id}
<CustomPreference
bind:preference={custom_preferences[id]}
remove={() => removePreference(id)}
/>
{/each}
</div>
</TabPane> </TabPane>
</TabContent> </TabContent>

View file

@ -0,0 +1,91 @@
<script lang="ts">
import { PreferenceSize, type CustomPreference } from "$lib/api/entities";
import IconButton from "$lib/components/IconButton.svelte";
import {
ButtonDropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
Icon,
Input,
InputGroup,
Tooltip,
} from "sveltestrap";
import icons from "../../../icons";
export let preference: CustomPreference;
export let remove: () => void;
let iconButton: HTMLElement;
let sizeButton: HTMLElement;
const toggleMuted = () => (preference.muted = !preference.muted);
const toggleFavourite = () => (preference.favourite = !preference.favourite);
let searchBox = "";
let filteredIcons: string[] = [];
$: filteredIcons = searchBox
? icons
.filter((icon) => icon.startsWith(searchBox))
.sort()
.slice(0, 15)
: [];
</script>
<InputGroup class="m-1">
<ButtonDropdown>
<Tooltip target={iconButton} placement="top">Change icon</Tooltip>
<DropdownToggle color="secondary" caret bind:inner={iconButton}>
<Icon name={preference.icon} alt="Current icon" />
</DropdownToggle>
<DropdownMenu>
<p class="px-2">
<Input type="text" placeholder="Search for icons" bind:value={searchBox} />
</p>
<DropdownItem divider />
{#each filteredIcons as icon}
<DropdownItem active={preference.icon === icon} on:click={() => (preference.icon = icon)}
><Icon name={icon} alt="Icon: {icon}" /> {icon}</DropdownItem
>
{:else}
<p class="px-2">Start typing to filter</p>
{/each}
</DropdownMenu>
</ButtonDropdown>
<input type="text" class="form-control" bind:value={preference.tooltip} />
<Tooltip target={sizeButton} placement="top">Change text size</Tooltip>
<ButtonDropdown>
<DropdownToggle color="secondary" caret bind:inner={sizeButton}>
<Icon name="type" alt="Text size" />
</DropdownToggle>
<DropdownMenu>
<DropdownItem
active={preference.size === PreferenceSize.Large}
on:click={() => (preference.size = PreferenceSize.Large)}>Large</DropdownItem
>
<DropdownItem
active={preference.size === PreferenceSize.Normal}
on:click={() => (preference.size = PreferenceSize.Normal)}>Medium</DropdownItem
>
<DropdownItem
active={preference.size === PreferenceSize.Small}
on:click={() => (preference.size = PreferenceSize.Small)}>Small</DropdownItem
>
</DropdownMenu>
</ButtonDropdown>
<IconButton
color="secondary"
icon={preference.favourite ? "star-fill" : "star"}
click={toggleFavourite}
active={preference.favourite}
tooltip="Treat like favourite"
/>
<IconButton
color="secondary"
icon="fonts"
click={toggleMuted}
active={preference.muted}
tooltip="Show as muted text"
/>
<IconButton color="danger" icon="trash3" tooltip="Remove preference" click={remove} />
</InputGroup>

View file

@ -31,7 +31,7 @@
let theme: string; let theme: string;
let currentUser: MeUser | null; let currentUser: MeUser | null;
let showMenu: boolean = false; let showMenu = false;
let isAdmin = false; let isAdmin = false;
let numReports = 0; let numReports = 0;

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Report } from "$lib/api/entities"; import type { Report } from "$lib/api/entities";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { Button, Card, CardBody, CardFooter, CardHeader } from "sveltestrap"; import { Card, CardBody, CardFooter, CardHeader } from "sveltestrap";
export let report: Report; export let report: Report;
</script> </script>

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { APIError } from "$lib/api/entities"; import type { APIError } from "$lib/api/entities";
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch"; import { apiFetchClient } from "$lib/api/fetch";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { import {
Button, Button,

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { APIError } from "$lib/api/entities"; import type { APIError } from "$lib/api/entities";
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch"; import { fastFetchClient } from "$lib/api/fetch";
import ErrorAlert from "$lib/components/ErrorAlert.svelte"; import ErrorAlert from "$lib/components/ErrorAlert.svelte";
import { addToast } from "$lib/toast"; import { addToast } from "$lib/toast";
import { DateTime } from "luxon"; import { DateTime } from "luxon";

2
go.mod
View file

@ -15,6 +15,7 @@ require (
github.com/go-chi/render v1.0.2 github.com/go-chi/render v1.0.2
github.com/gobwas/glob v0.2.3 github.com/gobwas/glob v0.2.3
github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang-jwt/jwt/v4 v4.5.0
github.com/google/uuid v1.3.0
github.com/jackc/pgx/v5 v5.3.1 github.com/jackc/pgx/v5 v5.3.1
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mediocregopher/radix/v4 v4.1.2 github.com/mediocregopher/radix/v4 v4.1.2
@ -40,7 +41,6 @@ require (
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
github.com/golang/protobuf v1.5.3 // indirect github.com/golang/protobuf v1.5.3 // indirect
github.com/google/s2a-go v0.1.0 // indirect github.com/google/s2a-go v0.1.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
github.com/gorilla/websocket v1.5.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect

View file

@ -0,0 +1,5 @@
-- +migrate Up
-- 2023-04-19: Add custom preferences
alter table users add column custom_preferences jsonb not null default '{}';

View file

@ -48,7 +48,7 @@ func run(c *cli.Context) error {
return err return err
} }
_, err = pg.UpdateUser(ctx, tx, u.ID, ptr("testing"), ptr("This is a bio!"), nil, ptr(false), &[]string{"https://pronouns.cc"}, nil) _, err = pg.UpdateUser(ctx, tx, u.ID, ptr("testing"), ptr("This is a bio!"), nil, ptr(false), &[]string{"https://pronouns.cc"}, nil, nil)
if err != nil { if err != nil {
fmt.Println("error setting user info:", err) fmt.Println("error setting user info:", err)
return err return err