Compare commits
2 Commits
1af719f838
...
5205097bb6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5205097bb6 | ||
|
|
a9edd8fbb8 |
109
src/App.css
109
src/App.css
@@ -33,6 +33,7 @@
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
.md pre code {
|
||||
@@ -1412,6 +1413,8 @@ a.global-player-title:hover {
|
||||
padding: 0 0.5rem 0.5rem;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.global-player-track > button {
|
||||
display: flex;
|
||||
@@ -2337,19 +2340,24 @@ body.has-player .chat-fab {
|
||||
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 {
|
||||
display: grid;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.profile-own-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.profile-own-actions .btn-border {
|
||||
margin-top: 0;
|
||||
}
|
||||
.invite-generate {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.invite-btn {
|
||||
padding: 0.3rem 0.9rem;
|
||||
@@ -2358,51 +2366,80 @@ body.has-player .chat-fab {
|
||||
background: transparent;
|
||||
color: var(--color-accent);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
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);
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 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;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.35rem 0.6rem;
|
||||
}
|
||||
/* Always-visible copy target. Sits first so it's the obvious tap. */
|
||||
.invite-copy-btn {
|
||||
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);
|
||||
.invite-panel-hint {
|
||||
margin-top: 0.4rem;
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background var(--dur-fast);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
.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);
|
||||
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) ── */
|
||||
@@ -2859,6 +2896,8 @@ body.has-player .chat-fab {
|
||||
trigger's viewport rect, flipping above it when there isn't room below */
|
||||
min-width: 180px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
background: var(--color-surface);
|
||||
border: var(--border-width) solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-xl);
|
||||
@@ -3902,6 +3941,8 @@ body.has-player .chat-fab {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useScrollLock } from "../hooks/useScrollLock";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
message: string;
|
||||
@@ -14,6 +15,7 @@ export function ConfirmModal(
|
||||
{ message, confirmLabel, onConfirm, onCancel }: ConfirmModalProps,
|
||||
) {
|
||||
const label = confirmLabel ?? t`Delete`;
|
||||
useScrollLock();
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ReactNode, useCallback, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useScrollLock } from "../hooks/useScrollLock";
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
@@ -25,12 +26,7 @@ export function Modal(
|
||||
onClose();
|
||||
}, [onBeforeClose, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, []);
|
||||
useScrollLock();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
|
||||
31
src/hooks/useScrollLock.ts
Normal file
31
src/hooks/useScrollLock.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
let locks = 0;
|
||||
|
||||
/**
|
||||
* Freezes the page's scroll while the calling component is mounted — for
|
||||
* modals, whose backdrop shouldn't let the feed slide underneath.
|
||||
*
|
||||
* Hiding the viewport's overflow drops its scrollbar, which would widen the
|
||||
* page and shift everything behind the backdrop. If a bar is showing, its
|
||||
* gutter is held for the duration of the lock instead, so the layout stays
|
||||
* put; no gutter is reserved the rest of the time. Locks are counted so a
|
||||
* confirm stacked on a modal doesn't release the page when it closes first.
|
||||
*/
|
||||
export function useScrollLock() {
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (locks++ === 0) {
|
||||
if (window.innerWidth > root.clientWidth) {
|
||||
root.style.scrollbarGutter = "stable";
|
||||
}
|
||||
root.style.overflowY = "hidden";
|
||||
}
|
||||
return () => {
|
||||
if (--locks === 0) {
|
||||
root.style.overflowY = "";
|
||||
root.style.scrollbarGutter = "";
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -4,11 +4,17 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: clip;
|
||||
/* Inherited by every scroller: a theme-tinted thumb on a clear track
|
||||
instead of the stock grey bar. */
|
||||
scrollbar-color: color-mix(in srgb, var(--color-text-muted) 45%, transparent)
|
||||
transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
overflow-x: clip;
|
||||
scrollbar-gutter: stable;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
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
@@ -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