Compare commits

...

3 commits

14 changed files with 179 additions and 72 deletions

View file

@ -71,6 +71,7 @@ public static class DatabaseQueryExtensions
{ {
member = await context.Members member = await context.Members
.Include(m => m.User) .Include(m => m.User)
.Include(m => m.ProfileFlags)
.Where(m => !m.User.Deleted) .Where(m => !m.User.Deleted)
.FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct); .FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct);
if (member != null) return member; if (member != null) return member;
@ -78,6 +79,7 @@ public static class DatabaseQueryExtensions
member = await context.Members member = await context.Members
.Include(m => m.User) .Include(m => m.User)
.Include(m => m.ProfileFlags)
.Where(m => !m.User.Deleted) .Where(m => !m.User.Deleted)
.FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct); .FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct);
if (member != null) return member; if (member != null) return member;

View file

@ -1,5 +1,6 @@
using System.Net; using System.Net;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace Foxnouns.Backend.Middleware; namespace Foxnouns.Backend.Middleware;

View file

@ -24,13 +24,14 @@ public class MemberRendererService(DatabaseContext db, Config config)
return members.Select(m => RenderPartialMember(m, renderUnlisted)); return members.Select(m => RenderPartialMember(m, renderUnlisted));
} }
public MemberResponse RenderMember(Member member, Token? token) public MemberResponse RenderMember(Member member, Token? token = null)
{ {
var renderUnlisted = token?.UserId == member.UserId && token.HasScope("user.read_hidden"); var renderUnlisted = token?.UserId == member.UserId && token.HasScope("user.read_hidden");
return new MemberResponse( return new MemberResponse(
member.Id, member.Sid, member.Name, member.DisplayName, member.Bio, member.Id, member.Sid, member.Name, member.DisplayName, member.Bio,
AvatarUrlFor(member), member.Links, member.Names, member.Pronouns, member.Fields, AvatarUrlFor(member), member.Links, member.Names, member.Pronouns, member.Fields,
member.ProfileFlags.Select(f => RenderPrideFlag(f.PrideFlag)),
RenderPartialUser(member.User), renderUnlisted ? member.Unlisted : null); RenderPartialUser(member.User), renderUnlisted ? member.Unlisted : null);
} }
@ -50,6 +51,9 @@ public class MemberRendererService(DatabaseContext db, Config config)
private string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp"; private string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp";
private UserRendererService.PrideFlagResponse RenderPrideFlag(PrideFlag flag) =>
new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description);
public record PartialMember( public record PartialMember(
Snowflake Id, Snowflake Id,
string Sid, string Sid,
@ -73,6 +77,7 @@ public class MemberRendererService(DatabaseContext db, Config config)
IEnumerable<FieldEntry> Names, IEnumerable<FieldEntry> Names,
IEnumerable<Pronoun> Pronouns, IEnumerable<Pronoun> Pronouns,
IEnumerable<Field> Fields, IEnumerable<Field> Fields,
IEnumerable<UserRendererService.PrideFlagResponse> Flags,
UserRendererService.PartialUser User, UserRendererService.PartialUser User,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
bool? Unlisted); bool? Unlisted);

View file

@ -21,3 +21,9 @@
@import "@fontsource/firago/400.css"; @import "@fontsource/firago/400.css";
@import "@fontsource/firago/400-italic.css"; @import "@fontsource/firago/400-italic.css";
@import "@fontsource/firago/700.css"; @import "@fontsource/firago/700.css";
.pride-flag {
height: 1.5rem;
max-width: 200px;
border-radius: 3px;
}

View file

@ -0,0 +1,111 @@
import { CustomPreference, User } from "~/lib/api/user";
import { Member } from "~/lib/api/member";
import { defaultAvatarUrl } from "~/lib/utils";
import ProfileFlag from "~/components/profile/ProfileFlag";
import ProfileLink from "~/components/profile/ProfileLink";
import ProfileField from "~/components/profile/ProfileField";
import { useTranslation } from "react-i18next";
import { renderMarkdown } from "~/lib/markdown";
export type Props = {
name: string;
fullName?: string;
avatarI18nKey: string;
profile: User | Member;
customPreferences: Record<string, CustomPreference>;
};
export default function BaseProfile({
name,
avatarI18nKey,
fullName,
profile,
customPreferences,
}: Props) {
const { t } = useTranslation();
const bio = renderMarkdown(profile.bio);
return (
<>
<div className="grid row-gap-3">
<div className="row">
<div className="col-md-4 text-center">
<img
src={profile.avatar_url || defaultAvatarUrl}
alt={t(avatarI18nKey, { username: name })}
width={200}
height={200}
className="rounded-circle img-fluid"
/>
{profile.flags && profile.bio && (
<div className="d-flex flex-wrap m-4">
{profile.flags.map((f, i) => (
<ProfileFlag flag={f} key={i} />
))}
</div>
)}
</div>
<div className="col-md">
{profile.display_name ? (
<>
<h2>{profile.display_name}</h2>
<p className="fs-5 text-body-secondary">{fullName || `@${name}`}</p>
</>
) : (
<>
<h2>{fullName || `@${name}`}</h2>
</>
)}
{bio && (
<>
<hr />
<p dangerouslySetInnerHTML={{ __html: bio }}></p>
</>
)}
</div>
{profile.links.length > 0 && (
<div className="col-md d-flex align-items-center">
<ul className="list-unstyled">
{profile.links.map((l, i) => (
<ProfileLink link={l} key={i} />
))}
</ul>
</div>
)}
</div>
<div className="row row-cols-1 row-cols-sm-2 row-cols-md-3">
{profile.names.length > 0 && (
<ProfileField
name={t("user.heading.names")}
entries={profile.names}
preferences={customPreferences}
/>
)}
{profile.pronouns.length > 0 && (
<ProfileField
name={t("user.heading.pronouns")}
entries={profile.pronouns}
preferences={customPreferences}
/>
)}
{profile.fields.map((f, i) => (
<ProfileField
name={f.name}
entries={f.entries}
preferences={customPreferences}
key={i}
/>
))}
</div>
</div>
{/* If a user or member has no bio, flags are displayed in a row below the other profile info, rather than just below the avatar */}
{profile.flags && !profile.bio && (
<div className="d-flex flex-wrap m-4">
{profile.flags.map((f, i) => (
<ProfileFlag flag={f} key={i} />
))}
</div>
)}
</>
);
}

View file

@ -1,5 +1,5 @@
import { CustomPreference, FieldEntry, Pronoun } from "~/lib/api/user"; import { CustomPreference, FieldEntry, Pronoun } from "~/lib/api/user";
import StatusLine from "~/components/StatusLine"; import StatusLine from "~/components/profile/StatusLine";
export default function ProfileField({ export default function ProfileField({
name, name,

View file

@ -0,0 +1,28 @@
import type { PrideFlag } from "~/lib/api/user";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
export default function ProfileFlag({ flag }: { flag: PrideFlag }) {
return (
<span className="mx-2 my-1">
<OverlayTrigger
key={flag.id}
placement="top"
overlay={
<Tooltip id={flag.id} aria-hidden={true}>
{flag.description ?? flag.name}
</Tooltip>
}
>
<span>
<img
className="pride-flag"
src={flag.image_url}
alt={flag.description ?? flag.name}
style={{ pointerEvents: "none" }}
/>
</span>
</OverlayTrigger>{" "}
{flag.name}
</span>
);
}

View file

@ -7,8 +7,8 @@ import {
Pronoun, Pronoun,
} from "~/lib/api/user"; } from "~/lib/api/user";
import classNames from "classnames"; import classNames from "classnames";
import StatusIcon from "~/components/StatusIcon"; import StatusIcon from "~/components/profile/StatusIcon";
import PronounLink from "~/components/PronounLink"; import PronounLink from "~/components/profile/PronounLink";
export default function StatusLine({ export default function StatusLine({
entry, entry,

View file

@ -0,0 +1,7 @@
import { Field, PartialMember, PrideFlag } from "~/lib/api/user";
export type Member = PartialMember & {
fields: Field[];
flags: PrideFlag[];
links: string[];
};

View file

@ -13,6 +13,7 @@ export type User = PartialUser & {
names: FieldEntry[]; names: FieldEntry[];
pronouns: Pronoun[]; pronouns: Pronoun[];
fields: Field[]; fields: Field[];
flags: PrideFlag[];
}; };
export type UserWithMembers = User & { members: PartialMember[] }; export type UserWithMembers = User & { members: PartialMember[] };
@ -50,6 +51,13 @@ export type Field = {
entries: FieldEntry[]; entries: FieldEntry[];
}; };
export type PrideFlag = {
id: string;
image_url: string;
name: string;
description: string | null;
};
export type CustomPreference = { export type CustomPreference = {
icon: string; icon: string;
tooltip: string; tooltip: string;

View file

@ -6,12 +6,10 @@ import { loader as rootLoader } from "~/root";
import { Alert, Button, Pagination } from "react-bootstrap"; import { Alert, Button, Pagination } 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 ProfileField from "~/components/ProfileField";
import { PersonPlusFill } from "react-bootstrap-icons"; import { PersonPlusFill } from "react-bootstrap-icons";
import { defaultAvatarUrl } from "~/lib/utils";
import MemberCard from "~/routes/$username/MemberCard"; import MemberCard from "~/routes/$username/MemberCard";
import { ReactNode } from "react"; import { ReactNode } from "react";
import BaseProfile from "~/components/profile/BaseProfile";
export const meta: MetaFunction<typeof loader> = ({ data }) => { export const meta: MetaFunction<typeof loader> = ({ data }) => {
const { user } = data!; const { user } = data!;
@ -44,7 +42,6 @@ export default function UserPage() {
const { meUser } = useRouteLoaderData<typeof rootLoader>("root") || { meUser: undefined }; const { meUser } = useRouteLoaderData<typeof rootLoader>("root") || { meUser: undefined };
const isMeUser = meUser && meUser.id === user.id; const isMeUser = meUser && meUser.id === user.id;
const bio = renderMarkdown(user.bio);
const paginationItems: ReactNode[] = []; const paginationItems: ReactNode[] = [];
for (let i = 0; i < pageCount; i++) { for (let i = 0; i < pageCount; i++) {
@ -82,70 +79,12 @@ export default function UserPage() {
</Trans> </Trans>
</Alert> </Alert>
)} )}
<div className="grid row-gap-3"> <BaseProfile
<div className="row"> name={user.username}
<div className="col-md-4 text-center"> avatarI18nKey={"user.avatar-alt"}
<img profile={user}
src={user.avatar_url || defaultAvatarUrl} customPreferences={user.custom_preferences}
alt={t("user.avatar-alt", { username: user.username })} />
width={200}
height={200}
className="rounded-circle img-fluid"
/>
</div>
<div className="col-md">
{user.display_name ? (
<>
<h2>{user.display_name}</h2>
<p className="fs-5 text-body-secondary">@{user.username}</p>
</>
) : (
<>
<h2>@{user.username}</h2>
</>
)}
{bio && (
<>
<hr />
<p dangerouslySetInnerHTML={{ __html: bio }}></p>
</>
)}
</div>
{user.links.length > 0 && (
<div className="col-md d-flex align-items-center">
<ul className="list-unstyled">
{user.links.map((l, i) => (
<ProfileLink link={l} key={i} />
))}
</ul>
</div>
)}
</div>
<div className="row row-cols-1 row-cols-sm-2 row-cols-md-3">
{user.names.length > 0 && (
<ProfileField
name={t("user.heading.names")}
entries={user.names}
preferences={user.custom_preferences}
/>
)}
{user.pronouns.length > 0 && (
<ProfileField
name={t("user.heading.pronouns")}
entries={user.pronouns}
preferences={user.custom_preferences}
/>
)}
{user.fields.map((f, i) => (
<ProfileField
name={f.name}
entries={f.entries}
preferences={user.custom_preferences}
key={i}
/>
))}
</div>
</div>
{(members.length > 0 || isMeUser) && ( {(members.length > 0 || isMeUser) && (
<> <>
<hr /> <hr />