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

@@ -2337,19 +2337,24 @@ body.has-player .chat-fab {
color: var(--color-accent); color: var(--color-accent);
} }
/* Grid sized by the button row; the invite panel below only fills that
width (width: 0 + min-width: 100% keeps it out of the sizing). */
.profile-own-actions { .profile-own-actions {
display: grid;
width: fit-content;
max-width: 100%;
gap: 0.5rem;
margin-top: 0.5rem;
}
.profile-own-actions-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.5rem;
align-items: center; align-items: center;
} }
.profile-own-actions .btn-border { .profile-own-actions .btn-border {
margin-top: 0; margin-top: 0;
} }
.invite-generate {
display: contents;
}
.invite-btn { .invite-btn {
padding: 0.3rem 0.9rem; padding: 0.3rem 0.9rem;
@@ -2358,51 +2363,80 @@ body.has-player .chat-fab {
background: transparent; background: transparent;
color: var(--color-accent); color: var(--color-accent);
font-size: var(--text-sm); font-size: var(--text-sm);
font-family: inherit;
cursor: pointer; cursor: pointer;
transition: background var(--dur-base), color var(--dur-base); transition: background var(--dur-base), color var(--dur-base);
} }
.invite-btn:hover { .invite-btn:hover:not(:disabled),
.invite-btn[aria-expanded="true"] {
background: var(--color-accent); background: var(--color-accent);
color: var(--color-on-accent); color: var(--color-on-accent);
} }
.invite-btn:disabled {
cursor: progress;
}
.invite-result { /* Raised above the info column's narrow-viewport overflow fade
(.profile-info::after) so the Copy button isn't washed out. */
.invite-panel {
position: relative;
z-index: 1;
width: 0;
min-width: 100%;
box-sizing: border-box;
}
.invite-panel:not(.invite-panel--error) {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 0.5rem; padding: 0.5rem;
max-width: 480px;
}
.invite-url {
font-size: var(--text-xs);
font-family: var(--font-mono, monospace);
color: var(--color-text-muted);
flex: 1;
min-width: 0;
overflow-x: auto;
white-space: nowrap;
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 0.35rem 0.6rem;
} }
/* Always-visible copy target. Sits first so it's the obvious tap. */ .invite-panel-hint {
.invite-copy-btn { margin-top: 0.4rem;
order: -1;
flex-shrink: 0;
padding: 0.2rem 0.65rem;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font-size: var(--text-xs); font-size: var(--text-xs);
cursor: pointer; color: var(--color-text-muted);
white-space: nowrap; text-align: center;
transition: background var(--dur-fast);
} }
.invite-copy-btn:hover {
/* Link field and Copy button fused into one control. */
.invite-url {
min-width: 0;
padding: 0.35rem 0.6rem;
border: 1.5px solid var(--color-border);
border-bottom: none;
border-radius: var(--radius-md) var(--radius-md) 0 0;
background: var(--color-surface-sunken);
color: var(--color-text);
font-family: inherit;
font-size: var(--text-sm);
text-overflow: ellipsis;
}
.invite-url:focus {
outline: none;
border-color: var(--color-accent);
}
.invite-copy-btn {
padding: 0.35rem 0.9rem;
border: 1.5px solid var(--color-accent);
border-radius: 0 0 var(--radius-md) var(--radius-md);
background: var(--color-accent); background: var(--color-accent);
color: var(--color-on-accent); color: var(--color-on-accent);
border-color: var(--color-accent); font-family: inherit;
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: background var(--dur-fast), border-color var(--dur-fast);
}
.invite-copy-btn:hover {
background: var(--color-accent-hover);
border-color: var(--color-accent-hover);
}
.invite-copy-btn--done,
.invite-copy-btn--done:hover {
background: var(--color-success-bg);
border-color: var(--color-success);
color: var(--color-success);
} }
/* ── Profile sub-pages (dumps / upvoted / playlists) ── */ /* ── Profile sub-pages (dumps / upvoted / playlists) ── */

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

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