Files
gerbeur/src/components/profile/ProfileHeader.tsx
khannurien 3e03626cc1 v4: text is edited where it stands through one component, InlineEdit, and a dump's note and a playlist's title and description join its title, a profile's description and email — those three were written apart, each with its own state, keys, buttons, error and CSS, and the note and the playlist could only be changed in their full editors. InlineEdit (components/form) shows the text; a click on it, or the ✎ after it, opens a field holding it, with Save and Cancel under it. A one-line field takes the text's own type in every style, from the class it is handed (dump-title, playlist-detail-title), and sits on the faint plate the text showed under the pointer, so that nothing moves but the buttons that come; a Markdown text opens the editor comments use. Enter saves a line, Ctrl+Enter a Markdown text, Escape backs out, a text left as it was closes without a request, a required one can't be emptied, a refused save says why in one line beside the buttons, and the focus goes back to the ✎. A click on a link in the text, or one that ends a selection, follows the link or keeps the selection. The ✎ is now a button with the action's name, where a div pretended to be one, and the text keeps its own role, a heading or Markdown. The plate is drawn under the text by a pseudo-element, tinted from its colour: the surface colour it had was the card's own, and showed nothing on the dump page. On a playlist's page the title edits in place beside Follow and Edit, which go to the row's end, and the description too; an empty note or description says what goes there to whoever may write it. DescriptionEditor and EmailEditor go.
The GeoCities marquee stops only for a link the keyboard focused: a link pressed with the pointer took the focus as well, which put the line back at its start and scrolled it, so the link slipped from under the pointer before the button came up and the click was lost. The next page's marquee also carries on from the very point the last one had reached, with the loop it showed: it worked its place out from when the loop began, which ran on while the crawl was paused under the pointer, and showed the feed as it was by then, of another width.

The chat's message field rests on one line, as tall as the Send button: it rested on two, 54 to 62 pixels against the button's 38, and the button sat low beside it. Both take --chat-composer-height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:08:53 +00:00

189 lines
6.2 KiB
TypeScript

/**
* Renders the header of a user's profile, where the signed-in user also edits
* their own avatar and email.
*
* @module
*/
import { useRef, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts";
import type {
PublicUser,
SelfUser,
UpdateUserRequest,
} from "../../../shared/user.ts";
import { expectOk, parseAPIResponse } from "../../utils/shared/api.ts";
import { deserializeSelfUser } from "../../utils/shared/deserialize.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { Avatar } from "../shared/Avatar.tsx";
import { ErrorCard } from "../shared/ErrorCard.tsx";
import { FollowUserButton } from "../social/FollowButton.tsx";
import { InlineEdit } from "../form/InlineEdit.tsx";
import { ProfileOwnActions } from "./ProfileOwnActions.tsx";
import { RoleChip } from "./RoleSwitcher.tsx";
import { userPath } from "../../../shared/paths.ts";
import { splitHandle } from "../../../shared/validation.ts";
import { homeUrl } from "../../utils/auth/home.ts";
/** Passed to {@link ProfileHeader} by `UserPublicProfile`, once loaded. */
export interface ProfileHeaderProps {
user: PublicUser;
isOwnProfile: boolean;
/** Called with the user as a new avatar left them. */
onUserChange: (user: PublicUser) => void;
}
/**
* Renders the avatar, username, role and inviter, and for a user from another
* instance, a link to their profile there. Visitors get a follow button. On
* their own profile, users change their avatar (`PUT /api/me/avatar`) and
* email (`PATCH /api/me`), unless their account comes from another instance, and invite
* someone; their settings, the site's tools and logging out are in the
* header's avatar menu.
*/
export function ProfileHeader(
{ user, isOwnProfile, onUserChange }: ProfileHeaderProps,
): React.JSX.Element {
const { user: me, authFetch, updateSession } = useAuth();
const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { name, host } = splitHandle(user.username);
// A user from another instance changes their avatar and email there.
const editable = isOwnProfile && !host;
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setAvatarError(null);
setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const res = await authFetch(`${API_URL}/api/me/avatar`, {
method: "PUT",
body: formData,
});
const body = parseAPIResponse<Wire<SelfUser>>(await res.json());
if (!body.success) {
setAvatarError(body.error.message);
return;
}
const self = deserializeSelfUser(body.data);
updateSession({ user: self });
onUserChange(self);
} catch {
setAvatarError(t`Upload failed`);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleEmailSave = async (email: string) => {
await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email } satisfies UpdateUserRequest),
}),
);
if (me) updateSession({ user: { ...me, email } });
};
return (
<div className="profile-header">
<div className="profile-avatar-wrapper">
<Avatar
userId={user.id}
username={user.username}
hasAvatar={!!user.avatarMime}
size={128}
version={user.updatedAt?.getTime()}
/>
{editable && (
<label className="avatar-change-overlay" title={t`Change avatar`}>
{uploading ? "…" : "✎"}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleAvatarUpload}
disabled={uploading}
style={{ display: "none" }}
/>
</label>
)}
</div>
<div className="profile-info">
<div className="profile-username-row">
<h1 className="profile-username">
{name}
{host && <span className="profile-username-host">@{host}</span>}
</h1>
<RoleChip role={user.role} />
</div>
{user.invitedByUsername
? (
<p className="profile-invited-by">
<Trans>invited by</Trans>{" "}
<Link
to={userPath(user.invitedByUsername)}
className="profile-invited-by-link"
>
@{user.invitedByUsername}
</Link>
</p>
)
// Without an inviter, a local user was here first; one from a
// trusted instance just came in.
: !host && (
<p className="profile-invited-by profile-invited-by--founding">
O.G.
</p>
)}
{host && (
<p className="profile-home">
<Trans>
Account on{" "}
<a href={homeUrl(user.username) ?? undefined}>{host}</a>
</Trans>
</p>
)}
{editable && (
<InlineEdit
value={me?.email ?? ""}
editable
label={t`Edit email`}
onSave={handleEmailSave}
kind="email"
required
className="profile-email"
>
<span className="profile-email-text">
{me?.email ?? t`Add email…`}
</span>
</InlineEdit>
)}
{avatarError && (
<ErrorCard
title={t`Failed to update avatar`}
message={avatarError}
/>
)}
{!isOwnProfile && (
<FollowUserButton
targetUserId={user.id}
targetUsername={user.username}
/>
)}
{isOwnProfile && <ProfileOwnActions />}
</div>
</div>
);
}