v3: invite panel reworked — "+ Invite someone" toggles a panel sized to the action row, easing in like the inline editors: link field fused with a full-width Copy, single-use/7-day hint, no longer clipped or faded on mobile

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-19 21:06:21 +00:00
parent 1af719f838
commit a9edd8fbb8
6 changed files with 645 additions and 544 deletions

View File

@@ -70,53 +70,103 @@ import {
useApiForm,
} from "../components/form/index.ts";
function InviteButton() {
/**
* Own-profile action row: "Invite someone" then `children` (log out). The
* invite button toggles a panel below the row holding a fresh single-use link;
* the panel eases in like the inline editors and takes the row's width.
*/
function ProfileOwnActions({ children }: { children: React.ReactNode }) {
const { authFetch } = useAuth();
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const ref = useHeightTransition<HTMLDivElement>(
(inviteUrl ?? "") + (error ?? ""),
);
async function generate() {
async function toggle() {
setError(null);
setCopied(false);
if (inviteUrl) {
setInviteUrl(null);
return;
}
setPending(true);
try {
const res = await authFetch(`${API_URL}/api/invites`, { method: "POST" });
const body = await res.json();
if (body.success) {
const url =
`${globalThis.location.origin}/register?token=${body.data.token}`;
setInviteUrl(url);
setInviteUrl(
`${globalThis.location.origin}/register?token=${body.data.token}`,
);
} else {
setError(t`Failed to generate invite`);
}
} catch {
setError(t`Failed to generate invite`);
} finally {
setPending(false);
}
}
async function copy() {
if (!inviteUrl) return;
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
if (inviteUrl) {
return (
<div className="invite-result">
<span className="invite-url">{inviteUrl}</span>
<button type="button" className="invite-copy-btn" onClick={copy}>
{copied ? t`Copied!` : t`Copy`}
</button>
</div>
);
try {
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard denied (e.g. insecure context): the field is selectable.
}
}
return (
<div className="invite-generate">
<button type="button" className="invite-btn" onClick={generate}>
<Trans>+ Invite someone</Trans>
</button>
<div className="profile-own-actions" ref={ref}>
<div className="profile-own-actions-row">
<button
type="button"
className="invite-btn"
onClick={toggle}
disabled={pending}
aria-expanded={inviteUrl !== null}
>
<Trans>+ Invite someone</Trans>
</button>
{children}
</div>
{inviteUrl && (
<div
className="invite-panel animate-in"
role="group"
aria-label={t`Invite link`}
>
<input
className="invite-url"
type="text"
readOnly
value={inviteUrl}
aria-label={t`Invite link`}
onFocus={(e) => e.currentTarget.select()}
/>
<button
type="button"
className={`invite-copy-btn${
copied ? " invite-copy-btn--done" : ""
}`}
onClick={copy}
>
{copied ? t`Copied!` : t`Copy`}
</button>
<span className="invite-panel-hint">
<Trans>Single use, valid for 7 days.</Trans>
</span>
</div>
)}
{error && (
<ErrorCard title={t`Failed to generate invite`} message={error} />
<div className="invite-panel invite-panel--error animate-in">
<ErrorCard title={t`Failed to generate invite`} message={error} />
</div>
)}
</div>
);
@@ -233,9 +283,7 @@ export function UserPublicProfile() {
// The "manage" tab is an admin/moderation area. On someone else's profile it
// holds per-user tools (role management); on your own it holds global tools
// (category management, with more to come).
const showManageTab = isOwnProfile
? canManageCategories
: canManageUsers;
const showManageTab = isOwnProfile ? canManageCategories : canManageUsers;
const availableTabs: ProfileTab[] = PROFILE_TABS.filter((t) => {
if (t === "settings") return isOwnProfile;
if (t === "manage") return showManageTab;
@@ -715,9 +763,7 @@ export function UserPublicProfile() {
const handleDescSaved = (description: string | undefined) => {
setState((s) =>
s.status === "loaded"
? { ...s, user: { ...s.user, description } }
: s
s.status === "loaded" ? { ...s, user: { ...s.user, description } } : s
);
setDescEditing(false);
};
@@ -847,12 +893,11 @@ export function UserPublicProfile() {
/>
)}
{isOwnProfile && (
<div className="profile-own-actions">
<InviteButton />
<ProfileOwnActions>
<button type="button" className="btn-border" onClick={logout}>
<Trans>Log out</Trans>
</button>
</div>
</ProfileOwnActions>
)}
</div>
</div>
@@ -1564,7 +1609,11 @@ const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [
function RoleChip({ role }: { role: Role }) {
if (role === "user") return null;
const label = ROLE_OPTIONS.find((o) => o.value === role)?.label;
return <span className={`profile-role-chip profile-role-chip--${role}`}>{label}</span>;
return (
<span className={`profile-role-chip profile-role-chip--${role}`}>
{label}
</span>
);
}
interface RoleSwitcherProps {