feat: add avatar/bio/links/names/pronouns to user page

This commit is contained in:
sam 2024-09-24 20:56:10 +02:00
parent 412d720abc
commit 862a64840e
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
16 changed files with 650 additions and 90 deletions

1
.gitignore vendored
View file

@ -5,3 +5,4 @@ node_modules/
config.ini
*.DotSettings.user
proxy-config.json
.DS_Store

View file

@ -2,6 +2,37 @@
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredUrls">
<list>
<option value="http://" />
<option value="http://0.0.0.0" />
<option value="http://127.0.0.1" />
<option value="http://activemq.apache.org/schema/" />
<option value="http://cxf.apache.org/schemas/" />
<option value="http://java.sun.com/" />
<option value="http://javafx.com/fxml" />
<option value="http://javafx.com/javafx/" />
<option value="http://json-schema.org/draft" />
<option value="http://localhost" />
<option value="http://maven.apache.org/POM/" />
<option value="http://maven.apache.org/xsd/" />
<option value="http://primefaces.org/ui" />
<option value="http://schema.cloudfoundry.org/spring/" />
<option value="http://schemas.xmlsoap.org/" />
<option value="http://tiles.apache.org/" />
<option value="http://www.ibm.com/webservices/xsd" />
<option value="http://www.jboss.com/xml/ns/" />
<option value="http://www.jboss.org/j2ee/schema/" />
<option value="http://www.springframework.org/schema/" />
<option value="http://www.springframework.org/security/tags" />
<option value="http://www.springframework.org/tags" />
<option value="http://www.thymeleaf.org" />
<option value="http://www.w3.org/" />
<option value="http://xmlns.jcp.org/" />
</list>
</option>
</inspection_tool>
<inspection_tool class="RequiredAttributes" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myAdditionalRequiredHtmlAttributes" value="column" />
</inspection_tool>

View file

@ -50,7 +50,8 @@ public class MembersController(
("bio", ValidationUtils.ValidateBio(req.Bio)),
("avatar", ValidationUtils.ValidateAvatar(req.Avatar)),
..ValidationUtils.ValidateFields(req.Fields, CurrentUser!.CustomPreferences),
..ValidationUtils.ValidateFieldEntries(req.Names?.ToArray(), CurrentUser!.CustomPreferences, "names")
..ValidationUtils.ValidateFieldEntries(req.Names?.ToArray(), CurrentUser!.CustomPreferences, "names"),
..ValidationUtils.ValidatePronouns(req.Pronouns?.ToArray(), CurrentUser!.CustomPreferences)
]);
var member = new Member

View file

@ -62,9 +62,28 @@ public class UsersController(
if (req.HasProperty(nameof(req.Links)))
{
// TODO: validate link length
user.Links = req.Links ?? [];
}
if (req.Names != null)
{
errors.AddRange(ValidationUtils.ValidateFieldEntries(req.Names, CurrentUser!.CustomPreferences, "names"));
user.Names = req.Names.ToList();
}
if (req.Pronouns != null)
{
errors.AddRange(ValidationUtils.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences));
user.Pronouns = req.Pronouns.ToList();
}
if (req.Fields != null)
{
errors.AddRange(ValidationUtils.ValidateFields(req.Fields.ToList(), CurrentUser!.CustomPreferences));
user.Fields = req.Fields.ToList();
}
if (req.HasProperty(nameof(req.Avatar)))
errors.Add(("avatar", ValidationUtils.ValidateAvatar(req.Avatar)));
@ -157,6 +176,9 @@ public class UsersController(
public string? Bio { get; init; }
public string? Avatar { get; init; }
public string[]? Links { get; init; }
public FieldEntry[]? Names { get; init; }
public Pronoun[]? Pronouns { get; init; }
public Field[]? Fields { get; init; }
}

View file

@ -156,7 +156,7 @@ public static class ValidationUtils
break;
}
errors = errors.Concat(ValidateFieldEntries(field.Entries, customPreferences, $"fields.{index}")).ToList();
errors = errors.Concat(ValidateFieldEntries(field.Entries, customPreferences, $"fields.{index}.entries")).ToList();
}
return errors;
@ -169,7 +169,7 @@ public static class ValidationUtils
var errors = new List<(string, ValidationError?)>();
if (entries.Length > Limits.FieldEntriesLimit)
errors.Add(($"{errorPrefix}.entries",
errors.Add((errorPrefix,
ValidationError.LengthError("Field has too many entries", 0, Limits.FieldEntriesLimit,
entries.Length)));
@ -181,12 +181,12 @@ public static class ValidationUtils
switch (entry.Value.Length)
{
case > Limits.FieldEntryTextLimit:
errors.Add(($"{errorPrefix}.entries.{entryIdx}.value",
errors.Add(($"{errorPrefix}.{entryIdx}.value",
ValidationError.LengthError("Field value is too long", 1, Limits.FieldEntryTextLimit,
entry.Value.Length)));
break;
case < 1:
errors.Add(($"{errorPrefix}.entries.{entryIdx}.value",
errors.Add(($"{errorPrefix}.{entryIdx}.value",
ValidationError.LengthError("Field value is too short", 1, Limits.FieldEntryTextLimit,
entry.Value.Length)));
break;
@ -195,7 +195,64 @@ public static class ValidationUtils
var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? [];
if (!DefaultStatusOptions.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status))
errors.Add(($"{errorPrefix}.entries.{entryIdx}.status",
errors.Add(($"{errorPrefix}.{entryIdx}.status",
ValidationError.GenericValidationError("Invalid status", entry.Status)));
}
return errors;
}
public static IEnumerable<(string, ValidationError?)> ValidatePronouns(Pronoun[]? entries,
IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences, string errorPrefix = "pronouns")
{
if (entries == null || entries.Length == 0) return [];
var errors = new List<(string, ValidationError?)>();
if (entries.Length > Limits.FieldEntriesLimit)
errors.Add((errorPrefix,
ValidationError.LengthError("Too many pronouns", 0, Limits.FieldEntriesLimit,
entries.Length)));
// Same as above, no overwhelming this function with a ridiculous amount of entries
if (entries.Length > Limits.FieldEntriesLimit + 50) return errors;
foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)))
{
switch (entry.Value.Length)
{
case > Limits.FieldEntryTextLimit:
errors.Add(($"{errorPrefix}.{entryIdx}.value",
ValidationError.LengthError("Pronoun value is too long", 1, Limits.FieldEntryTextLimit,
entry.Value.Length)));
break;
case < 1:
errors.Add(($"{errorPrefix}.{entryIdx}.value",
ValidationError.LengthError("Pronoun value is too short", 1, Limits.FieldEntryTextLimit,
entry.Value.Length)));
break;
}
if (entry.DisplayText != null)
{
switch (entry.DisplayText.Length)
{
case > Limits.FieldEntryTextLimit:
errors.Add(($"{errorPrefix}.{entryIdx}.value",
ValidationError.LengthError("Pronoun display text is too long", 1, Limits.FieldEntryTextLimit,
entry.Value.Length)));
break;
case < 1:
errors.Add(($"{errorPrefix}.{entryIdx}.value",
ValidationError.LengthError("Pronoun display text is too short", 1, Limits.FieldEntryTextLimit,
entry.Value.Length)));
break;
}
}
var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? [];
if (!DefaultStatusOptions.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status))
errors.Add(($"{errorPrefix}.{entryIdx}.status",
ValidationError.GenericValidationError("Invalid status", entry.Status)));
}

View file

@ -0,0 +1,16 @@
import * as icons from "react-bootstrap-icons";
import { IconProps as BaseIconProps } from "react-bootstrap-icons";
import { pascalCase } from "change-case";
const startsWithNumberRegex = /^\d/;
export default function Icon({ iconName, ...props }: BaseIconProps & { iconName: string }) {
let icon = pascalCase(iconName);
if (startsWithNumberRegex.test(icon)) {
icon = `Icon${icon}`;
}
// eslint-disable-next-line import/namespace
const BootstrapIcon = icons[icon as keyof typeof icons];
return <BootstrapIcon {...props} />;
}

View file

@ -0,0 +1,27 @@
import { Globe } from "react-bootstrap-icons";
export default function ProfileLink({ link }: { link: string }) {
const isLink = link.startsWith("http://") || link.startsWith("https://");
let displayLink = link;
if (link.startsWith("http://")) displayLink = link.substring("http://".length);
else if (link.startsWith("https://")) displayLink = link.substring("https://".length);
if (displayLink.endsWith("/")) displayLink = displayLink.substring(0, displayLink.length - 1);
if (isLink) {
return (
<a href={link} className="text-decoration-none" rel="me nofollow noreferrer" target="_blank">
<li className="py-2 py-lg-0">
<Globe className="text-body" aria-hidden={true} />{" "}
<span className="text-decoration-underline">{displayLink}</span>
</li>
</a>
);
}
return (
<li className="py-2 py-lg-0">
<Globe aria-hidden={true} /> {displayLink}
</li>
);
}

View file

@ -0,0 +1,32 @@
import { Pronoun } from "~/lib/api/user";
import { Link } from "@remix-run/react";
export default function PronounLink({ pronoun }: { pronoun: Pronoun }) {
let displayText: string;
if (pronoun.display_text) displayText = pronoun.display_text;
else {
const split = pronoun.value.split("/");
if (split.length === 5) displayText = split.splice(0, 2).join("/");
else displayText = pronoun.value;
}
let link: string;
const linkBase = pronoun.value
.split("/")
.map((value) => encodeURIComponent(value))
.join("/");
if (pronoun.display_text) {
link = `${linkBase},${encodeURIComponent(pronoun.display_text)}`;
} else {
link = linkBase;
}
return pronoun.value.split("/").length === 5 ? (
<Link className="text-reset" to={`/pronouns/${link}`}>
{displayText}
</Link>
) : (
<>{displayText}</>
);
}

View file

@ -0,0 +1,34 @@
import { CustomPreference, defaultPreferences } from "~/lib/api/user";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import Icon from "~/components/KeyedIcon";
export default function StatusIcon({
preferences,
status,
}: {
preferences: Record<string, CustomPreference>;
status: string;
}) {
const mergedPrefs = Object.assign({}, defaultPreferences, preferences);
const currentPref = status in mergedPrefs ? mergedPrefs[status] : defaultPreferences.missing;
const id = crypto.randomUUID();
return (
<>
<OverlayTrigger
key={id}
placement="top"
overlay={
<Tooltip id={id} aria-hidden={true}>
{currentPref.tooltip}
</Tooltip>
}
>
<span className="d-inline-block">
<Icon iconName={currentPref.icon} aria-hidden={true} style={{ pointerEvents: "none" }} />
</span>
</OverlayTrigger>
<span className="visually-hidden">{currentPref.tooltip}:</span>
</>
);
}

View file

@ -0,0 +1,36 @@
import {
CustomPreference,
defaultPreferences,
FieldEntry,
PreferenceSize,
Pronoun,
} from "~/lib/api/user";
import classNames from "classnames";
import { ReactNode } from "react";
import StatusIcon from "~/components/StatusIcon";
export default function StatusLine({
entry,
preferences,
children,
}: {
entry: FieldEntry | Pronoun;
preferences: Record<string, CustomPreference>;
children: ReactNode;
}) {
const mergedPrefs = Object.assign({}, defaultPreferences, preferences);
const currentPref =
entry.status in mergedPrefs ? mergedPrefs[entry.status] : defaultPreferences.missing;
const classes = classNames({
"text-muted": currentPref.muted,
"fw-bold fs-5": currentPref.size == PreferenceSize.Large,
"fs-6": currentPref.size == PreferenceSize.Small,
});
return (
<span className={classes}>
<StatusIcon preferences={preferences} status={entry.status} /> {children}
</span>
);
}

View file

@ -12,6 +12,7 @@ export type User = PartialUser & {
names: FieldEntry[];
pronouns: Pronoun[];
fields: Field[];
custom_preferences: Record<string, CustomPreference>;
};
export type UserWithMembers = User & { members: PartialMember[] };
@ -47,3 +48,62 @@ export type Field = {
name: string;
entries: FieldEntry[];
};
export type CustomPreference = {
icon: string;
tooltip: string;
muted: boolean;
favourite: boolean;
size: PreferenceSize;
};
export enum PreferenceSize {
Large = "LARGE",
Normal = "NORMAL",
Small = "SMALL",
}
export const defaultPreferences = Object.freeze({
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,
},
});

View file

@ -0,0 +1,22 @@
import MarkdownIt from "markdown-it";
import sanitize from "sanitize-html";
const md = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
}).disable(["heading", "lheading", "link", "table", "blockquote"]);
const unsafeMd = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
});
export function renderMarkdown(src: string | null) {
return src ? sanitize(md.render(src)) : null;
}
export function renderUnsafeMarkdown(src: string) {
return sanitize(unsafeMd.render(src));
}

View file

@ -1,7 +1,14 @@
import { json, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { redirect, useLoaderData } from "@remix-run/react";
import { Link, redirect, useLoaderData, useRouteLoaderData } from "@remix-run/react";
import { UserWithMembers } from "~/lib/api/user";
import serverRequest from "~/lib/request.server";
import { loader as rootLoader } from "~/root";
import { Alert } from "react-bootstrap";
import { Trans, useTranslation } from "react-i18next";
import { renderMarkdown } from "~/lib/markdown";
import ProfileLink from "~/components/ProfileLink";
import StatusLine from "~/components/StatusLine";
import PronounLink from "~/components/PronounLink";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
const { user } = data!;
@ -25,11 +32,87 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export default function UserPage() {
const { t } = useTranslation();
const { user } = useLoaderData<typeof loader>();
const { meUser } = useRouteLoaderData<typeof rootLoader>("root") || { meUser: undefined };
const bio = renderMarkdown(user.bio);
return (
<>
hello! this is the user page for @{user.username}. their ID is {user.id}
{meUser && meUser.id === user.id && (
<Alert variant="secondary">
<Trans t={t} i18nKey="user.own-profile-alert">
You are currently viewing your <strong>public</strong> profile.
<br />
<Link to={`/@${user.username}/edit`}>Edit your profile</Link>
</Trans>
</Alert>
)}
<div className="grid row-gap-3">
<div className="row">
<div className="col-md-4 text-center">
<img
src={user.avatar_url || "https://pronouns.cc/default/512.webp"}
alt={t("user.avatar-alt", { username: user.username })}
width={200}
height={200}
className="rounded-circle img-fluid"
/>
</div>
<div className="col-md">
{user.display_name ? (
<>
<h2>{user.display_name}</h2>
<p className="fs-5 text-body-secondary">@{user.username}</p>
</>
) : (
<>
<h2>@{user.username}</h2>
</>
)}
{bio && (
<>
<hr />
<p dangerouslySetInnerHTML={{ __html: bio }}></p>
</>
)}
</div>
{user.links.length > 0 && (
<div className="col-md d-flex align-items-center">
<ul className="list-unstyled">
{user.links.map((l, i) => (
<ProfileLink link={l} key={i} />
))}
</ul>
</div>
)}
</div>
<div className="row row-cols-1 row-cols-sm-2 row-cols-md-3">
{user.names.length > 0 && (
<div className="col-md">
<h3>{t("user.heading.names")}</h3>
<ul className="list-unstyled fs-5">
{user.names.map((n, i) => (
<StatusLine entry={n} preferences={user.custom_preferences} key={i}>
{n.value}
</StatusLine>
))}
</ul>
</div>
)}
{user.pronouns.length > 0 && (
<div className="col-md">
<h3>{t("user.heading.pronouns")}</h3>
{user.pronouns.map((p, i) => (
<StatusLine entry={p} preferences={user.custom_preferences} key={i}>
<PronounLink pronoun={p} />
</StatusLine>
))}
</div>
)}
</div>
</div>
</>
);
}

View file

@ -18,6 +18,7 @@
"@remix-run/react": "^2.11.2",
"@remix-run/serve": "^2.11.2",
"bootstrap": "^5.3.3",
"change-case": "^5.4.4",
"classnames": "^2.5.1",
"compression": "^1.7.4",
"cookie": "^0.6.0",
@ -29,13 +30,15 @@
"i18next-fs-backend": "^2.3.2",
"i18next-http-backend": "^2.6.1",
"isbot": "^4.1.0",
"markdown-it": "^14.1.0",
"morgan": "^1.10.0",
"react": "^18.2.0",
"react-bootstrap": "^2.10.4",
"react-bootstrap-icons": "^1.11.4",
"react-dom": "^18.2.0",
"react-i18next": "^15.0.1",
"remix-i18next": "^6.3.0"
"remix-i18next": "^6.3.0",
"sanitize-html": "^2.13.0"
},
"devDependencies": {
"@fontsource/firago": "^5.0.11",
@ -43,9 +46,11 @@
"@types/compression": "^1.7.5",
"@types/cookie": "^0.6.0",
"@types/express": "^4.17.21",
"@types/markdown-it": "^14.1.2",
"@types/morgan": "^1.9.9",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/sanitize-html": "^2.13.0",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"eslint": "^8.38.0",

View file

@ -1,79 +1,87 @@
{
"error": {
"heading": "An error occurred",
"validation": {
"too-long": "Value is too long, maximum length is {{maxLength}}, current length is {{actualLength}}.",
"too-short": "Value is too short, minimum length is {{minLength}}, current length is {{actualLength}}.",
"disallowed-value": "The value <1>{{actualValue}}</1> is not allowed here. Allowed values are: <4>{{allowedValues}}</4>",
"generic": "The value <1>{{actualValue}}</1> is not allowed here. Reason: {{reason}}",
"generic-no-value": "The value you entered is not allowed here. Reason: {{reason}}"
},
"errors": {
"authentication-error": "There was an error validating your credentials.",
"authentication-required": "You need to log in.",
"bad-request": "Server rejected your input, please check anything for errors.",
"forbidden": "You are not allowed to perform that action.",
"generic-error": "An unknown error occurred.",
"internal-server-error": "Server experienced an internal error, please try again later.",
"member-not-found": "Member not found, please check your spelling and try again.",
"user-not-found": "User not found, please check your spelling and try again."
},
"title": "An error occurred",
"more-info": "Click here for a more detailed error"
},
"navbar": {
"view-profile": "View profile",
"settings": "Settings",
"log-out": "Log out",
"log-in": "Log in or sign up",
"theme": "Theme",
"theme-auto": "Automatic",
"theme-dark": "Dark",
"theme-light": "Light"
},
"log-in": {
"callback": {
"title": {
"discord-success": "Log in with Discord",
"discord-register": "Register with Discord"
},
"success": "Successfully logged in!",
"success-link": "Welcome back, <1>@{{username}}</1>!",
"redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.",
"remote-username": {
"discord": "Your discord username"
},
"username": "Username",
"sign-up-button": "Sign up",
"invalid-ticket": "Invalid ticket (it might have been too long since you logged in with Discord), please <2>try again</2>.",
"invalid-username": "Invalid username",
"username-taken": "That username is already taken, please try something else."
},
"title": "Log in",
"form-title": "Log in with email",
"email": "Email address",
"password": "Password",
"log-in-button": "Log in",
"register-with-email": "Register with email",
"3rd-party": {
"title": "Log in with another service",
"desc": "If you prefer, you can also log in with one of these services:",
"discord": "Log in with Discord",
"google": "Log in with Google",
"tumblr": "Log in with Tumblr"
},
"invalid-credentials": "Invalid email address or password, please check your spelling and try again."
},
"welcome": {
"title": "Welcome",
"header": "Welcome to pronouns.cc!",
"blurb": "{welcome.blurb}",
"customize-profile": "Customize your profile",
"customize-profile-blurb": "{welcome.customize-profile-blurb}",
"create-members": "Create members",
"create-members-blurb": "{welcome.create-members-blurb}",
"custom-preferences": "Customize your preferences",
"custom-preferences-blurb": "{welcome.custom-preferences-blurb}",
"profile-button": "Go to your profile"
}
"error": {
"heading": "An error occurred",
"validation": {
"too-long": "Value is too long, maximum length is {{maxLength}}, current length is {{actualLength}}.",
"too-short": "Value is too short, minimum length is {{minLength}}, current length is {{actualLength}}.",
"disallowed-value": "The value <1>{{actualValue}}</1> is not allowed here. Allowed values are: <4>{{allowedValues}}</4>",
"generic": "The value <1>{{actualValue}}</1> is not allowed here. Reason: {{reason}}",
"generic-no-value": "The value you entered is not allowed here. Reason: {{reason}}"
},
"errors": {
"authentication-error": "There was an error validating your credentials.",
"authentication-required": "You need to log in.",
"bad-request": "Server rejected your input, please check anything for errors.",
"forbidden": "You are not allowed to perform that action.",
"generic-error": "An unknown error occurred.",
"internal-server-error": "Server experienced an internal error, please try again later.",
"member-not-found": "Member not found, please check your spelling and try again.",
"user-not-found": "User not found, please check your spelling and try again."
},
"title": "An error occurred",
"more-info": "Click here for a more detailed error"
},
"navbar": {
"view-profile": "View profile",
"settings": "Settings",
"log-out": "Log out",
"log-in": "Log in or sign up",
"theme": "Theme",
"theme-auto": "Automatic",
"theme-dark": "Dark",
"theme-light": "Light"
},
"user": {
"own-profile-alert": "You are currently viewing your <1>public</1> profile.<3></3><4>Edit your profile</4>",
"avatar-alt": "Avatar for @{{username}}",
"heading": {
"names": "Names",
"pronouns": "Pronouns"
}
},
"log-in": {
"callback": {
"title": {
"discord-success": "Log in with Discord",
"discord-register": "Register with Discord"
},
"success": "Successfully logged in!",
"success-link": "Welcome back, <1>@{{username}}</1>!",
"redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.",
"remote-username": {
"discord": "Your discord username"
},
"username": "Username",
"sign-up-button": "Sign up",
"invalid-ticket": "Invalid ticket (it might have been too long since you logged in with Discord), please <2>try again</2>.",
"invalid-username": "Invalid username",
"username-taken": "That username is already taken, please try something else."
},
"title": "Log in",
"form-title": "Log in with email",
"email": "Email address",
"password": "Password",
"log-in-button": "Log in",
"register-with-email": "Register with email",
"3rd-party": {
"title": "Log in with another service",
"desc": "If you prefer, you can also log in with one of these services:",
"discord": "Log in with Discord",
"google": "Log in with Google",
"tumblr": "Log in with Tumblr"
},
"invalid-credentials": "Invalid email address or password, please check your spelling and try again."
},
"welcome": {
"title": "Welcome",
"header": "Welcome to pronouns.cc!",
"blurb": "{welcome.blurb}",
"customize-profile": "Customize your profile",
"customize-profile-blurb": "{welcome.customize-profile-blurb}",
"create-members": "Create members",
"create-members-blurb": "{welcome.create-members-blurb}",
"custom-preferences": "Customize your preferences",
"custom-preferences-blurb": "{welcome.custom-preferences-blurb}",
"profile-button": "Go to your profile"
}
}

View file

@ -1336,6 +1336,19 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/linkify-it@^5":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76"
integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==
"@types/markdown-it@^14.1.2":
version "14.1.2"
resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61"
integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==
dependencies:
"@types/linkify-it" "^5"
"@types/mdurl" "^2"
"@types/mdast@^3.0.0":
version "3.0.15"
resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.15.tgz#49c524a263f30ffa28b71ae282f813ed000ab9f5"
@ -1343,6 +1356,11 @@
dependencies:
"@types/unist" "^2"
"@types/mdurl@^2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd"
integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
"@types/mdx@^2.0.0", "@types/mdx@^2.0.5":
version "2.0.13"
resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.13.tgz#68f6877043d377092890ff5b298152b0a21671bd"
@ -1414,6 +1432,13 @@
"@types/prop-types" "*"
csstype "^3.0.2"
"@types/sanitize-html@^2.13.0":
version "2.13.0"
resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.13.0.tgz#ac3620e867b7c68deab79c72bd117e2049cdd98e"
integrity sha512-X31WxbvW9TjIhZZNyNBZ/p5ax4ti7qsNDBDEnH4zAgmEh35YnFD1UiS6z9Cd34kKm0LslFW0KPmTQzu/oGtsqQ==
dependencies:
htmlparser2 "^8.0.0"
"@types/semver@^7.5.0":
version "7.5.8"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e"
@ -2075,6 +2100,11 @@ chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
change-case@^5.4.4:
version "5.4.4"
resolved "https://registry.yarnpkg.com/change-case/-/change-case-5.4.4.tgz#0d52b507d8fb8f204343432381d1a6d7bff97a02"
integrity sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==
character-entities-html4@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b"
@ -3760,6 +3790,16 @@ html-parse-stringify@^3.0.1:
dependencies:
void-elements "3.1.0"
htmlparser2@^8.0.0:
version "8.0.2"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21"
integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils "^3.0.1"
entities "^4.4.0"
htmlparser2@^9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-9.1.0.tgz#cdb498d8a75a51f739b61d3f718136c369bc8c23"
@ -4120,6 +4160,11 @@ is-plain-obj@^4.0.0:
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0"
integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==
is-plain-object@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
is-reference@^3.0.0:
version "3.0.2"
resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-3.0.2.tgz#154747a01f45cd962404ee89d43837af2cba247c"
@ -4370,6 +4415,13 @@ lilconfig@^3.0.0:
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.2.tgz#e4a7c3cb549e3a606c8dcc32e5ae1005e62c05cb"
integrity sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==
linkify-it@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421"
integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==
dependencies:
uc.micro "^2.0.0"
loader-utils@^3.2.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-3.3.1.tgz#735b9a19fd63648ca7adbd31c2327dfe281304e5"
@ -4452,6 +4504,18 @@ markdown-extensions@^1.0.0:
resolved "https://registry.yarnpkg.com/markdown-extensions/-/markdown-extensions-1.1.1.tgz#fea03b539faeaee9b4ef02a3769b455b189f7fc3"
integrity sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q==
markdown-it@^14.1.0:
version "14.1.0"
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45"
integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
dependencies:
argparse "^2.0.1"
entities "^4.4.0"
linkify-it "^5.0.0"
mdurl "^2.0.0"
punycode.js "^2.3.1"
uc.micro "^2.1.0"
matcher-collection@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/matcher-collection/-/matcher-collection-2.0.1.tgz#90be1a4cf58d6f2949864f65bb3b0f3e41303b29"
@ -4590,6 +4654,11 @@ mdast-util-to-string@^3.0.0, mdast-util-to-string@^3.1.0:
dependencies:
"@types/mdast" "^3.0.0"
mdurl@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
media-query-parser@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/media-query-parser/-/media-query-parser-2.0.2.tgz#ff79e56cee92615a304a1c2fa4f2bd056c0a1d29"
@ -5380,6 +5449,11 @@ parse-ms@^2.1.0:
resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d"
integrity sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==
parse-srcset@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1"
integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==
parse5-htmlparser2-tree-adapter@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz#23c2cc233bcf09bb7beba8b8a69d46b08c62c2f1"
@ -5473,7 +5547,7 @@ periscopic@^3.0.0:
estree-walker "^3.0.0"
is-reference "^3.0.0"
picocolors@^1.0.0, picocolors@^1.0.1:
picocolors@^1.0.0, picocolors@^1.0.1, picocolors@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.0.tgz#5358b76a78cde483ba5cef6a9dc9671440b27d59"
integrity sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==
@ -5570,6 +5644,15 @@ postcss-value-parser@^4.1.0:
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.3.11:
version "8.4.47"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.47.tgz#5bf6c9a010f3e724c503bf03ef7947dcb0fea365"
integrity sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==
dependencies:
nanoid "^3.3.7"
picocolors "^1.1.0"
source-map-js "^1.2.1"
postcss@^8.4.19, postcss@^8.4.43:
version "8.4.45"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.45.tgz#538d13d89a16ef71edbf75d895284ae06b79e603"
@ -5684,6 +5767,11 @@ pumpify@^1.3.3:
inherits "^2.0.3"
pump "^2.0.0"
punycode.js@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
punycode@^2.1.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
@ -6097,6 +6185,18 @@ safe-regex-test@^1.0.3:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
sanitize-html@^2.13.0:
version "2.13.0"
resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.13.0.tgz#71aedcdb777897985a4ea1877bf4f895a1170dae"
integrity sha512-Xff91Z+4Mz5QiNSLdLWwjgBDm5b1RU6xBT0+12rapjiaR7SwfRdjw8f+6Rir2MXKLrDicRFHdb51hGOAxmsUIA==
dependencies:
deepmerge "^4.2.2"
escape-string-regexp "^4.0.0"
htmlparser2 "^8.0.0"
is-plain-object "^5.0.0"
parse-srcset "^1.0.2"
postcss "^8.3.11"
sass@1.77.6:
version "1.77.6"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.6.tgz#898845c1348078c2e6d1b64f9ee06b3f8bd489e4"
@ -6233,6 +6333,11 @@ sort-keys@^5.0.0:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af"
integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==
source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
source-map-support@^0.5.21:
version "0.5.21"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
@ -6339,8 +6444,16 @@ string-hash@^1.1.1:
resolved "https://registry.yarnpkg.com/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b"
integrity sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
name string-width-cjs
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -6442,7 +6555,14 @@ stringify-entities@^4.0.0:
character-entities-html4 "^2.0.0"
character-entities-legacy "^3.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -6724,6 +6844,11 @@ typescript@^5.1.6:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
uc.micro@^2.0.0, uc.micro@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
ufo@^1.5.3:
version "1.5.4"
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.4.tgz#16d6949674ca0c9e0fbbae1fa20a71d7b1ded754"