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; 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; if (!templateMatcher.TryMatch(url, new())) continue;
var httpMethodAttribute = endpoint.Metadata.GetMetadata<HttpMethodAttribute>(); var httpMethodAttribute = endpoint.Metadata.GetMetadata<HttpMethodAttribute>();
if (httpMethodAttribute != null && if (httpMethodAttribute != null &&

View file

@ -42,7 +42,8 @@ public class MembersController(
[HttpPost("/api/v2/users/@me/members")] [HttpPost("/api/v2/users/@me/members")]
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)] [ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
[Authorize("member.create")] [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([ ValidationUtils.Validate([
("name", ValidationUtils.ValidateMemberName(req.Name)), ("name", ValidationUtils.ValidateMemberName(req.Name)),

View file

@ -104,7 +104,8 @@ public class UsersController(
[HttpPatch("@me/custom-preferences")] [HttpPatch("@me/custom-preferences")]
[Authorize("user.update")] [Authorize("user.update")]
[ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)] [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)); ValidationUtils.Validate(ValidateCustomPreferences(req));
@ -194,7 +195,8 @@ public class UsersController(
[HttpPatch("@me/settings")] [HttpPatch("@me/settings")]
[Authorize("user.read_hidden", "user.update")] [Authorize("user.read_hidden", "user.update")]
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)] [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); 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"]; private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"];
public static async Task 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); await objectStorageService.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash), ct);
public static async Task 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); await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct);
public static async Task<Stream> ConvertBase64UriToAvatar(this string uri) public static async Task<Stream> ConvertBase64UriToAvatar(this string uri)

View file

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

View file

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

View file

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

View file

@ -156,7 +156,8 @@ public static class ValidationUtils
break; 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; return errors;
@ -238,12 +239,14 @@ public static class ValidationUtils
{ {
case > Limits.FieldEntryTextLimit: case > Limits.FieldEntryTextLimit:
errors.Add(($"{errorPrefix}.{entryIdx}.value", 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))); entry.Value.Length)));
break; break;
case < 1: case < 1:
errors.Add(($"{errorPrefix}.{entryIdx}.value", 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))); entry.Value.Length)));
break; break;
} }

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

View file

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

View file

@ -3,6 +3,7 @@ export type PartialUser = {
username: string; username: string;
display_name?: string | null; display_name?: string | null;
avatar_url?: string | null; avatar_url?: string | null;
custom_preferences: Record<string, CustomPreference>;
}; };
export type User = PartialUser & { export type User = PartialUser & {
@ -12,7 +13,6 @@ export type User = PartialUser & {
names: FieldEntry[]; names: FieldEntry[];
pronouns: Pronoun[]; pronouns: Pronoun[];
fields: Field[]; fields: Field[];
custom_preferences: Record<string, CustomPreference>;
}; };
export type UserWithMembers = User & { members: PartialMember[] }; export type UserWithMembers = User & { members: PartialMember[] };
@ -35,6 +35,7 @@ export type PartialMember = {
avatar_url: string | null; avatar_url: string | null;
names: FieldEntry[]; names: FieldEntry[];
pronouns: Pronoun[]; pronouns: Pronoun[];
unlisted: boolean | null;
}; };
export type FieldEntry = { export type FieldEntry = {
@ -63,6 +64,10 @@ export enum PreferenceSize {
Small = "SMALL", Small = "SMALL",
} }
export function mergePreferences(prefs: Record<string, CustomPreference>) {
return Object.assign({}, defaultPreferences, prefs);
}
export const defaultPreferences = Object.freeze({ export const defaultPreferences = Object.freeze({
favourite: { favourite: {
icon: "heart-fill", 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 { UserWithMembers } from "~/lib/api/user";
import serverRequest from "~/lib/request.server"; import serverRequest from "~/lib/request.server";
import { loader as rootLoader } from "~/root"; import { loader as rootLoader } from "~/root";
import { Alert } from "react-bootstrap"; import { Alert, Button } from "react-bootstrap";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { renderMarkdown } from "~/lib/markdown"; import { renderMarkdown } from "~/lib/markdown";
import ProfileLink from "~/components/ProfileLink"; import ProfileLink from "~/components/ProfileLink";
import ProfileField from "~/components/ProfileField"; 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 }) => { export const meta: MetaFunction<typeof loader> = ({ data }) => {
const { user } = data!; const { user } = data!;
@ -39,16 +42,17 @@ export default function UserPage() {
const { user } = useLoaderData<typeof loader>(); const { user } = useLoaderData<typeof loader>();
const { meUser } = useRouteLoaderData<typeof rootLoader>("root") || { meUser: undefined }; const { meUser } = useRouteLoaderData<typeof rootLoader>("root") || { meUser: undefined };
const isMeUser = meUser && meUser.id === user.id;
const bio = renderMarkdown(user.bio); const bio = renderMarkdown(user.bio);
return ( return (
<> <>
{meUser && meUser.id === user.id && ( {isMeUser && (
<Alert variant="secondary"> <Alert variant="secondary">
<Trans t={t} i18nKey="user.own-profile-alert"> <Trans t={t} i18nKey="user.own-profile-alert">
You are currently viewing your <strong>public</strong> profile. You are currently viewing your <strong>public</strong> profile.
<br /> <br />
<Link to={`/@${user.username}/edit`}>Edit your profile</Link> <Link to="/edit/profile">Edit your profile</Link>
</Trans> </Trans>
</Alert> </Alert>
)} )}
@ -56,7 +60,7 @@ export default function UserPage() {
<div className="row"> <div className="row">
<div className="col-md-4 text-center"> <div className="col-md-4 text-center">
<img <img
src={user.avatar_url || "https://pronouns.cc/default/512.webp"} src={user.avatar_url || defaultAvatarUrl}
alt={t("user.avatar-alt", { username: user.username })} alt={t("user.avatar-alt", { username: user.username })}
width={200} width={200}
height={200} height={200}
@ -116,6 +120,39 @@ export default function UserPage() {
))} ))}
</div> </div>
</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

@ -25,19 +25,20 @@
"view-profile": "View profile", "view-profile": "View profile",
"settings": "Settings", "settings": "Settings",
"log-out": "Log out", "log-out": "Log out",
"log-in": "Log in or sign up", "log-in": "Log in or sign up"
"theme": "Theme",
"theme-auto": "Automatic",
"theme-dark": "Dark",
"theme-light": "Light"
}, },
"user": { "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>", "own-profile-alert": "You are currently viewing your <1>public</1> profile.<3></3><4>Edit your profile</4>",
"avatar-alt": "Avatar for @{{username}}", "avatar-alt": "Avatar for @{{username}}",
"heading": { "heading": {
"names": "Names", "names": "Names",
"pronouns": "Pronouns" "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": { "log-in": {
"callback": { "callback": {

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>