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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user