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));
@ -180,8 +181,8 @@ public class UsersController(
public Pronoun[]? Pronouns { get; init; } public Pronoun[]? Pronouns { get; init; }
public Field[]? Fields { get; init; } public Field[]? Fields { get; init; }
} }
[HttpGet("@me/settings")] [HttpGet("@me/settings")]
[Authorize("user.read_hidden")] [Authorize("user.read_hidden")]
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)]
@ -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

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

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,12 +44,12 @@
</Target> </Target>
<ItemGroup> <ItemGroup>
<EmbeddedResource Watch="false" Include="..\.version" LogicalName="version" /> <EmbeddedResource Watch="false" Include="..\.version" LogicalName="version"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="..\.dockerignore"> <Content Include="..\.dockerignore">
<Link>.dockerignore</Link> <Link>.dockerignore</Link>
</Content> </Content>
</ItemGroup> </ItemGroup>
</Project> </Project>

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

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

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,2 +1,2 @@
@using Foxnouns.Backend @using Foxnouns.Backend
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View file

@ -1,3 +1,3 @@
@{ @{
Layout = "~/Views/Mail/Layout.cshtml"; 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 { 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

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