Compare commits

...

4 commits

22 changed files with 285 additions and 147 deletions

View file

@ -60,7 +60,8 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
{
if (endpoint.RoutePattern.RawText == null) continue;
var templateMatcher = new TemplateMatcher(TemplateParser.Parse(endpoint.RoutePattern.RawText), new RouteValueDictionary());
var templateMatcher = new TemplateMatcher(TemplateParser.Parse(endpoint.RoutePattern.RawText),
new RouteValueDictionary());
if (!templateMatcher.TryMatch(url, new())) continue;
var httpMethodAttribute = endpoint.Metadata.GetMetadata<HttpMethodAttribute>();
if (httpMethodAttribute != null &&

View file

@ -42,7 +42,8 @@ public class MembersController(
[HttpPost("/api/v2/users/@me/members")]
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
[Authorize("member.create")]
public async Task<IActionResult> CreateMemberAsync([FromBody] CreateMemberRequest req, CancellationToken ct = default)
public async Task<IActionResult> CreateMemberAsync([FromBody] CreateMemberRequest req,
CancellationToken ct = default)
{
ValidationUtils.Validate([
("name", ValidationUtils.ValidateMemberName(req.Name)),

View file

@ -104,7 +104,8 @@ public class UsersController(
[HttpPatch("@me/custom-preferences")]
[Authorize("user.update")]
[ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateCustomPreferencesAsync([FromBody] List<CustomPreferencesUpdateRequest> req, CancellationToken ct = default)
public async Task<IActionResult> UpdateCustomPreferencesAsync([FromBody] List<CustomPreferencesUpdateRequest> req,
CancellationToken ct = default)
{
ValidationUtils.Validate(ValidateCustomPreferences(req));
@ -180,8 +181,8 @@ public class UsersController(
public Pronoun[]? Pronouns { get; init; }
public Field[]? Fields { get; init; }
}
[HttpGet("@me/settings")]
[Authorize("user.read_hidden")]
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)]
@ -194,7 +195,8 @@ public class UsersController(
[HttpPatch("@me/settings")]
[Authorize("user.read_hidden", "user.update")]
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateUserSettingsAsync([FromBody] UpdateUserSettingsRequest req, CancellationToken ct = default)
public async Task<IActionResult> UpdateUserSettingsAsync([FromBody] UpdateUserSettingsRequest req,
CancellationToken ct = default)
{
var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);

View file

@ -14,11 +14,13 @@ public static class AvatarObjectExtensions
private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"];
public static async Task
DeleteMemberAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash, CancellationToken ct = default) =>
DeleteMemberAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash,
CancellationToken ct = default) =>
await objectStorageService.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash), ct);
public static async Task
DeleteUserAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash, CancellationToken ct = default) =>
DeleteUserAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash,
CancellationToken ct = default) =>
await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct);
public static async Task<Stream> ConvertBase64UriToAvatar(this string uri)

View file

@ -100,11 +100,11 @@ public static class WebApplicationExtensions
// Transient jobs
.AddTransient<MemberAvatarUpdateInvocable>()
.AddTransient<UserAvatarUpdateInvocable>();
if (!config.Logging.EnableMetrics)
services.AddHostedService<BackgroundMetricsCollectionService>();
});
return builder.Services;
}

View file

@ -8,34 +8,34 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Coravel" Version="5.0.4" />
<PackageReference Include="Coravel.Mailer" Version="5.0.1" />
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3" />
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2" />
<PackageReference Include="JetBrains.Annotations" Version="2024.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Coravel" Version="5.0.4"/>
<PackageReference Include="Coravel.Mailer" Version="5.0.1"/>
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2"/>
<PackageReference Include="JetBrains.Annotations" Version="2024.2.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Minio" Version="6.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NodaTime" Version="3.1.11" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4" />
<PackageReference Include="Npgsql.Json.NET" Version="8.0.3" />
<PackageReference Include="prometheus-net" Version="8.2.1" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageReference Include="Sentry.AspNetCore" Version="4.9.0" />
<PackageReference Include="Serilog" Version="4.0.1" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageReference Include="Minio" Version="6.0.3"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="NodaTime" Version="3.1.11"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4"/>
<PackageReference Include="Npgsql.Json.NET" Version="8.0.3"/>
<PackageReference Include="prometheus-net" Version="8.2.1"/>
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
<PackageReference Include="Sentry.AspNetCore" Version="4.9.0"/>
<PackageReference Include="Serilog" Version="4.0.1"/>
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1"/>
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0"/>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/>
</ItemGroup>
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">
@ -44,12 +44,12 @@
</Target>
<ItemGroup>
<EmbeddedResource Watch="false" Include="..\.version" LogicalName="version" />
<EmbeddedResource Watch="false" Include="..\.version" LogicalName="version"/>
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>

View file

@ -2,7 +2,8 @@ using Coravel.Mailer.Mail;
namespace Foxnouns.Backend.Mailables;
public class AccountCreationMailable(Config config, AccountCreationMailableView view) : Mailable<AccountCreationMailableView>
public class AccountCreationMailable(Config config, AccountCreationMailableView view)
: Mailable<AccountCreationMailableView>
{
public override void Build()
{

View file

@ -11,6 +11,7 @@ public class MemberRendererService(DatabaseContext db, Config config)
public async Task<IEnumerable<PartialMember>> RenderUserMembersAsync(User user, Token? token)
{
var canReadHiddenMembers = token != null && token.UserId == user.Id && token.HasScope("member.read");
var renderUnlisted = token != null && token.UserId == user.Id && token.HasScope("user.read_hidden");
var canReadMemberList = !user.ListHidden || canReadHiddenMembers;
IEnumerable<Member> members = canReadMemberList
@ -20,7 +21,7 @@ public class MemberRendererService(DatabaseContext db, Config config)
.ToListAsync()
: [];
if (!canReadHiddenMembers) members = members.Where(m => !m.Unlisted);
return members.Select(RenderPartialMember);
return members.Select(m => RenderPartialMember(m, renderUnlisted));
}
public MemberResponse RenderMember(Member member, Token? token)
@ -34,10 +35,11 @@ public class MemberRendererService(DatabaseContext db, Config config)
}
private UserRendererService.PartialUser RenderPartialUser(User user) =>
new(user.Id, user.Username, user.DisplayName, AvatarUrlFor(user));
new(user.Id, user.Username, user.DisplayName, AvatarUrlFor(user), user.CustomPreferences);
public PartialMember RenderPartialMember(Member member) => new(member.Id, member.Name,
member.DisplayName, member.Bio, AvatarUrlFor(member), member.Names, member.Pronouns);
public PartialMember RenderPartialMember(Member member, bool renderUnlisted = false) => new(member.Id, member.Name,
member.DisplayName, member.Bio, AvatarUrlFor(member), member.Names, member.Pronouns,
renderUnlisted ? member.Unlisted : null);
private string? AvatarUrlFor(Member member) =>
member.Avatar != null ? $"{config.MediaBaseUrl}/members/{member.Id}/avatars/{member.Avatar}.webp" : null;
@ -52,7 +54,9 @@ public class MemberRendererService(DatabaseContext db, Config config)
string? Bio,
string? AvatarUrl,
IEnumerable<FieldEntry> Names,
IEnumerable<Pronoun> Pronouns);
IEnumerable<Pronoun> Pronouns,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
bool? Unlisted);
public record MemberResponse(
Snowflake Id,

View file

@ -39,7 +39,7 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
return new UserResponse(
user.Id, user.Username, user.DisplayName, user.Bio, user.MemberTitle, AvatarUrlFor(user), user.Links,
user.Names, user.Pronouns, user.Fields, user.CustomPreferences,
renderMembers ? members.Select(memberRenderer.RenderPartialMember) : null,
renderMembers ? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden)) : null,
renderAuthMethods
? authMethods.Select(a => new AuthenticationMethodResponse(
a.Id, a.AuthType, a.RemoteId,
@ -52,7 +52,7 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
}
public PartialUser RenderPartialUser(User user) =>
new(user.Id, user.Username, user.DisplayName, AvatarUrlFor(user));
new(user.Id, user.Username, user.DisplayName, AvatarUrlFor(user), user.CustomPreferences);
private string? AvatarUrlFor(User user) =>
user.Avatar != null ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" : null;
@ -94,6 +94,7 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
Snowflake Id,
string Username,
string? DisplayName,
string? AvatarUrl
string? AvatarUrl,
Dictionary<Snowflake, User.CustomPreference> CustomPreferences
);
}

View file

@ -79,7 +79,7 @@ public static class AuthUtils
return false;
}
}
public static bool TryParseToken(string? input, out byte[] rawToken)
{
rawToken = [];

View file

@ -156,7 +156,8 @@ public static class ValidationUtils
break;
}
errors = errors.Concat(ValidateFieldEntries(field.Entries, customPreferences, $"fields.{index}.entries")).ToList();
errors = errors.Concat(ValidateFieldEntries(field.Entries, customPreferences, $"fields.{index}.entries"))
.ToList();
}
return errors;
@ -238,12 +239,14 @@ public static class ValidationUtils
{
case > Limits.FieldEntryTextLimit:
errors.Add(($"{errorPrefix}.{entryIdx}.value",
ValidationError.LengthError("Pronoun display text is too long", 1, Limits.FieldEntryTextLimit,
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,
ValidationError.LengthError("Pronoun display text is too short", 1,
Limits.FieldEntryTextLimit,
entry.Value.Length)));
break;
}

View file

@ -1,2 +1,2 @@
@using Foxnouns.Backend
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View file

@ -1,3 +1,3 @@
@{
Layout = "~/Views/Mail/Layout.cshtml";
}
}

View file

@ -1,4 +1,4 @@
import { CustomPreference, defaultPreferences } from "~/lib/api/user";
import { CustomPreference, defaultPreferences, mergePreferences } from "~/lib/api/user";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import Icon from "~/components/KeyedIcon";
@ -9,7 +9,7 @@ export default function StatusIcon({
preferences: Record<string, CustomPreference>;
status: string;
}) {
const mergedPrefs = Object.assign({}, defaultPreferences, preferences);
const mergedPrefs = mergePreferences(preferences);
const currentPref = status in mergedPrefs ? mergedPrefs[status] : defaultPreferences.missing;
const id = crypto.randomUUID();

View file

@ -2,11 +2,11 @@ import {
CustomPreference,
defaultPreferences,
FieldEntry,
mergePreferences,
PreferenceSize,
Pronoun,
} from "~/lib/api/user";
import classNames from "classnames";
import { ReactNode } from "react";
import StatusIcon from "~/components/StatusIcon";
import PronounLink from "~/components/PronounLink";
@ -17,7 +17,7 @@ export default function StatusLine({
entry: FieldEntry | Pronoun;
preferences: Record<string, CustomPreference>;
}) {
const mergedPrefs = Object.assign({}, defaultPreferences, preferences);
const mergedPrefs = mergePreferences(preferences);
const currentPref =
entry.status in mergedPrefs ? mergedPrefs[entry.status] : defaultPreferences.missing;

View file

@ -3,6 +3,7 @@ export type PartialUser = {
username: string;
display_name?: string | null;
avatar_url?: string | null;
custom_preferences: Record<string, CustomPreference>;
};
export type User = PartialUser & {
@ -12,7 +13,6 @@ export type User = PartialUser & {
names: FieldEntry[];
pronouns: Pronoun[];
fields: Field[];
custom_preferences: Record<string, CustomPreference>;
};
export type UserWithMembers = User & { members: PartialMember[] };
@ -35,6 +35,7 @@ export type PartialMember = {
avatar_url: string | null;
names: FieldEntry[];
pronouns: Pronoun[];
unlisted: boolean | null;
};
export type FieldEntry = {
@ -63,6 +64,10 @@ export enum PreferenceSize {
Small = "SMALL",
}
export function mergePreferences(prefs: Record<string, CustomPreference>) {
return Object.assign({}, defaultPreferences, prefs);
}
export const defaultPreferences = Object.freeze({
favourite: {
icon: "heart-fill",

View file

@ -0,0 +1 @@
export const defaultAvatarUrl = "https://pronouns.cc/default/512.webp";

View file

@ -0,0 +1,74 @@
import {
defaultPreferences,
mergePreferences,
PartialMember,
PartialUser,
Pronoun,
} from "~/lib/api/user";
import { Link } from "@remix-run/react";
import { defaultAvatarUrl } from "~/lib/utils";
import { useTranslation } from "react-i18next";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import { Lock } from "react-bootstrap-icons";
export default function MemberCard({ user, member }: { user: PartialUser; member: PartialMember }) {
const { t } = useTranslation();
const mergedPrefs = mergePreferences(user.custom_preferences);
const pronouns: Pronoun[] = [];
for (const pronoun of member.pronouns) {
const pref =
pronoun.status in mergedPrefs ? mergedPrefs[pronoun.status] : defaultPreferences.missing;
if (pref.favourite) pronouns.push(pronoun);
}
const displayedPronouns = pronouns
.map((pronoun) => {
if (pronoun.display_text) {
return pronoun.display_text;
} else {
const split = pronoun.value.split("/");
if (split.length === 5) return split.splice(0, 2).join("/");
return pronoun.value;
}
})
.join(", ");
return (
<div className="col">
<Link to={`/@${user.username}/${member.name}`}>
<img
src={member.avatar_url || defaultAvatarUrl}
alt={t("user.member-avatar-alt", { name: member.name })}
width={200}
height={200}
loading="lazy"
className="rounded-circle img-fluid"
/>
</Link>
<p className="m-2">
<Link to={`/@${user.username}/${member.name}`}>
{member.display_name ?? member.name}
{member.unlisted === true && (
<>
<OverlayTrigger
key={member.id}
placement="top"
overlay={
<Tooltip id={member.id} aria-hidden={true}>
{t("user.member-hidden")}
</Tooltip>
}
>
<span className="d-inline-block">
<Lock aria-hidden={true} style={{ pointerEvents: "none" }} />
</span>
</OverlayTrigger>
</>
)}
</Link>
{displayedPronouns && <>{displayedPronouns}</>}
</p>
</div>
);
}

View file

@ -3,11 +3,14 @@ import { Link, redirect, useLoaderData, useRouteLoaderData } from "@remix-run/re
import { UserWithMembers } from "~/lib/api/user";
import serverRequest from "~/lib/request.server";
import { loader as rootLoader } from "~/root";
import { Alert } from "react-bootstrap";
import { Alert, Button } from "react-bootstrap";
import { Trans, useTranslation } from "react-i18next";
import { renderMarkdown } from "~/lib/markdown";
import ProfileLink from "~/components/ProfileLink";
import ProfileField from "~/components/ProfileField";
import { PersonPlusFill } from "react-bootstrap-icons";
import { defaultAvatarUrl } from "~/lib/utils";
import MemberCard from "~/routes/$username/MemberCard";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
const { user } = data!;
@ -39,16 +42,17 @@ export default function UserPage() {
const { user } = useLoaderData<typeof loader>();
const { meUser } = useRouteLoaderData<typeof rootLoader>("root") || { meUser: undefined };
const isMeUser = meUser && meUser.id === user.id;
const bio = renderMarkdown(user.bio);
return (
<>
{meUser && meUser.id === user.id && (
{isMeUser && (
<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>
<Link to="/edit/profile">Edit your profile</Link>
</Trans>
</Alert>
)}
@ -56,7 +60,7 @@ export default function UserPage() {
<div className="row">
<div className="col-md-4 text-center">
<img
src={user.avatar_url || "https://pronouns.cc/default/512.webp"}
src={user.avatar_url || defaultAvatarUrl}
alt={t("user.avatar-alt", { username: user.username })}
width={200}
height={200}
@ -116,6 +120,39 @@ export default function UserPage() {
))}
</div>
</div>
{user.members.length > 0 ||
(isMeUser && (
<>
<hr />
<h2>
{user.member_title || t("user.heading.members")}{" "}
{/* @ts-expect-error using as=Link causes an error here, even though it runs completely fine */}
<Button as={Link} to="/settings/members/create" variant="success">
<PersonPlusFill /> {t("user.create-member-button")}
</Button>
</h2>
<div className="grid">
{user.members.length === 0 ? (
<div>
<Trans t={t} i18nKey="user.no-members-blurb">
You don&apos;t have any members yet.
<br />
Members are sub-profiles that can have their own avatar, names, pronouns, and preferred terms.
<br />
You can create a new member with the &quot;Create member&quot; button above.{" "}
<span className="text-muted">(only you can see this)</span>
</Trans>
</div>
) : (
<div className="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 text-center">
{user.members.map((member, i) => (
<MemberCard user={user} member={member} key={i} />
))}
</div>
)}
</div>
</>
))}
</>
);
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 47.5 47.5" style="enable-background:new 0 0 47.5 47.5;" xml:space="preserve" version="1.1" id="svg2"><metadata id="metadata8"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata><defs id="defs6"><clipPath id="clipPath16" clipPathUnits="userSpaceOnUse"><path id="path18" d="M 0,38 38,38 38,0 0,0 0,38 Z"/></clipPath></defs><g transform="matrix(1.25,0,0,-1.25,0,47.5)" id="g10"><g id="g12"><g clip-path="url(#clipPath16)" id="g14"><g transform="translate(35.3467,20.1069)" id="g20"><path id="path22" style="fill:#aa8ed6;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 -8.899,3.294 -3.323,10.891 c -0.128,0.42 -0.516,0.708 -0.956,0.708 -0.439,0 -0.828,-0.288 -0.956,-0.708 L -17.456,3.294 -26.356,0 c -0.393,-0.146 -0.653,-0.52 -0.653,-0.938 0,-0.418 0.26,-0.793 0.653,-0.937 l 8.896,-3.293 3.323,-11.223 c 0.126,-0.425 0.516,-0.716 0.959,-0.716 0.443,0 0.833,0.291 0.959,0.716 l 3.324,11.223 8.896,3.293 c 0.392,0.144 0.652,0.519 0.652,0.937 C 0.653,-0.52 0.393,-0.146 0,0"/></g><g transform="translate(15.3472,9.1064)" id="g24"><path id="path26" style="fill:#fcab40;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 -2.313,0.856 -0.9,3.3 c -0.119,0.436 -0.514,0.738 -0.965,0.738 -0.451,0 -0.846,-0.302 -0.965,-0.738 l -0.9,-3.3 L -8.356,0 c -0.393,-0.145 -0.653,-0.52 -0.653,-0.937 0,-0.418 0.26,-0.793 0.653,-0.938 l 2.301,-0.853 0.907,-3.622 c 0.111,-0.444 0.511,-0.756 0.97,-0.756 0.458,0 0.858,0.312 0.97,0.756 L -2.301,-2.728 0,-1.875 c 0.393,0.145 0.653,0.52 0.653,0.938 C 0.653,-0.52 0.393,-0.145 0,0"/></g><g transform="translate(11.0093,30.769)" id="g28"><path id="path30" style="fill:#5dadec;fill-opacity:1;fill-rule:nonzero;stroke:none" d="M 0,0 -2.365,0.875 -3.24,3.24 c -0.146,0.393 -0.52,0.653 -0.938,0.653 -0.419,0 -0.793,-0.26 -0.938,-0.653 L -5.992,0.875 -8.356,0 c -0.393,-0.146 -0.653,-0.52 -0.653,-0.938 0,-0.418 0.26,-0.792 0.653,-0.938 l 2.364,-0.875 0.876,-2.365 c 0.145,-0.393 0.519,-0.653 0.938,-0.653 0.418,0 0.792,0.26 0.938,0.653 L -2.365,-2.751 0,-1.876 c 0.393,0.146 0.653,0.52 0.653,0.938 C 0.653,-0.52 0.393,-0.146 0,0"/></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -1,87 +1,88 @@
{
"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"
}
"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"
},
"user": {
"member-avatar-alt": "Avatar for {{name}}",
"member-hidden": "This member is unlisted, and not shown in your public member list.",
"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",
"members": "Members"
},
"create-member-button": "Create member",
"no-members-blurb": "You don't have any members yet.<1></1>Members are sub-profiles that can have their own avatar, names, pronouns, and preferred terms.<3></3>You can create a new member with the \"Create member\" button above. <6>(only you can see this)</6>"
},
"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

@ -0,0 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=foxnouns/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=pronounscc/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>