Phase 17: UI Optimization & Polish #24
+394
-220
@@ -55,6 +55,22 @@ const sectionLabelStyle: React.CSSProperties = {
|
||||
export function AdminPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Phone detection for toast bottom offset
|
||||
const phone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||
|
||||
// Success toast state (D-08)
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
// Auto-dismiss toast after 3000ms — mirrors SyncStateToast lines 72-79
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const timer = setTimeout(() => setToast(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast]);
|
||||
|
||||
// Two-tab navigation state (D-10)
|
||||
const [activeTab, setActiveTab] = useState<'members' | 'settings'>('members');
|
||||
|
||||
// Credential sheet state
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [sheetMode, setSheetMode] = useState<CredentialSheetMode>('admin-add');
|
||||
@@ -185,6 +201,29 @@ export function AdminPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Roving tabindex keyboard handler for the two-tab strip (D-10)
|
||||
function handleTabKeyDown(e: React.KeyboardEvent, current: 'members' | 'settings') {
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const next = current === 'members' ? 'settings' : 'members';
|
||||
setActiveTab(next);
|
||||
(
|
||||
e.currentTarget.parentElement?.querySelector(
|
||||
`[id="admin-tab-${next}"]`,
|
||||
) as HTMLElement | null
|
||||
)?.focus();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const prev = current === 'settings' ? 'members' : 'settings';
|
||||
setActiveTab(prev);
|
||||
(
|
||||
e.currentTarget.parentElement?.querySelector(
|
||||
`[id="admin-tab-${prev}"]`,
|
||||
) as HTMLElement | null
|
||||
)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Open credential sheet for a member
|
||||
function openSheet(member: AdminMember, buttonRef: React.RefObject<HTMLButtonElement | null>) {
|
||||
// Capture the button so focus can return on close
|
||||
@@ -219,6 +258,7 @@ export function AdminPage() {
|
||||
setCreateError(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
setToast('Member added.');
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = err instanceof Error ? err.message : 'server';
|
||||
@@ -266,7 +306,7 @@ export function AdminPage() {
|
||||
{/* Page heading */}
|
||||
<h1
|
||||
style={{
|
||||
margin: '0 0 var(--space-8, 32px) 0',
|
||||
margin: '0 0 var(--space-6, 24px) 0',
|
||||
fontSize: 'var(--text-heading-size, 18px)',
|
||||
fontWeight: 600,
|
||||
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||
@@ -276,7 +316,60 @@ export function AdminPage() {
|
||||
Admin Settings
|
||||
</h1>
|
||||
|
||||
{/* ── MEMBERS section ─────────────────────────────────────────────── */}
|
||||
{/* ── Two-tab strip (D-10) ──────────────────────────────────────────── */}
|
||||
<div
|
||||
role="tablist"
|
||||
style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid var(--color-border-subtle, var(--color-border))',
|
||||
marginBottom: 'var(--space-6, 24px)',
|
||||
}}
|
||||
>
|
||||
{(['members', 'settings'] as const).map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
role="tab"
|
||||
id={`admin-tab-${id}`}
|
||||
aria-selected={activeTab === id}
|
||||
aria-controls={`admin-panel-${id}`}
|
||||
tabIndex={activeTab === id ? 0 : -1}
|
||||
onClick={() => setActiveTab(id)}
|
||||
onKeyDown={(e) => handleTabKeyDown(e, id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
minHeight: '44px',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: activeTab === id ? 600 : 400,
|
||||
color:
|
||||
activeTab === id
|
||||
? 'var(--color-text-primary)'
|
||||
: 'var(--color-text-secondary)',
|
||||
borderBottom:
|
||||
activeTab === id
|
||||
? '2px solid var(--color-member-0)'
|
||||
: '2px solid transparent',
|
||||
transition: 'color 0.1s ease, border-color 0.1s ease',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
{id === 'members' ? 'Members & Accounts' : 'Settings'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Tab panel: Members & Accounts ────────────────────────────────── */}
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="admin-panel-members"
|
||||
aria-labelledby="admin-tab-members"
|
||||
tabIndex={0}
|
||||
hidden={activeTab !== 'members'}
|
||||
>
|
||||
|
||||
{/* ── MEMBERS section ───────────────────────────────────────────── */}
|
||||
<section aria-label="Members" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
||||
<div style={sectionLabelStyle}>Members</div>
|
||||
|
||||
@@ -324,6 +417,242 @@ export function AdminPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── LOCAL ACCOUNTS section ──────────────────────────────────────── */}
|
||||
<section aria-label="Local Accounts" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
||||
<div style={sectionLabelStyle}>Local Accounts</div>
|
||||
|
||||
{/* Surface 11A — Add member inline form */}
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-border-subtle, var(--color-border))',
|
||||
borderRadius: '8px',
|
||||
padding: 'var(--space-4, 16px)',
|
||||
marginBottom: 'var(--space-6, 24px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-4, 16px)',
|
||||
}}
|
||||
>
|
||||
Add member
|
||||
</div>
|
||||
|
||||
{/* Display name */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-display-name"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Display name
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-display-name"
|
||||
type="text"
|
||||
value={createDisplayName}
|
||||
onChange={(e) => setCreateDisplayName(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-username"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-username"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
value={createUsername}
|
||||
onChange={(e) => setCreateUsername(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Initial password */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Initial password
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={createPassword}
|
||||
onChange={(e) => setCreatePassword(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirm password */}
|
||||
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-confirm-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={createConfirmPassword}
|
||||
onChange={(e) => setCreateConfirmPassword(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inline error */}
|
||||
{createError && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-destructive)',
|
||||
marginBottom: 'var(--space-3, 12px)',
|
||||
}}
|
||||
>
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action row */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={createSubmitDisabled}
|
||||
onClick={() => {
|
||||
setCreateError(null);
|
||||
createMemberMutation.mutate();
|
||||
}}
|
||||
style={{
|
||||
background: createSubmitDisabled
|
||||
? 'var(--color-border, #e2e4e9)'
|
||||
: 'var(--color-member-0, #4a90d9)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
cursor: createSubmitDisabled ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-6, 24px)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
transition: 'background 0.15s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2, 8px)',
|
||||
}}
|
||||
>
|
||||
{createMemberMutation.isPending && (
|
||||
<Loader2
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
Add member
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>{/* end admin-panel-members */}
|
||||
|
||||
{/* ── Tab panel: Settings ───────────────────────────────────────────── */}
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="admin-panel-settings"
|
||||
aria-labelledby="admin-tab-settings"
|
||||
tabIndex={0}
|
||||
hidden={activeTab !== 'settings'}
|
||||
>
|
||||
|
||||
{/* ── SHARED CALENDAR section ──────────────────────────────────────── */}
|
||||
<section aria-label="Shared Calendar" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
||||
<div style={sectionLabelStyle}>Shared Calendar</div>
|
||||
@@ -605,7 +934,9 @@ export function AdminPage() {
|
||||
color: 'var(--color-text-primary)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
cursor: 'pointer',
|
||||
background: active ? 'var(--color-member-0, #4A90D9)' : 'transparent',
|
||||
background: active
|
||||
? 'var(--color-member-0, #4A90D9)'
|
||||
: 'transparent',
|
||||
...(active ? { color: '#ffffff' } : null),
|
||||
minHeight: '44px',
|
||||
display: 'flex',
|
||||
@@ -688,231 +1019,50 @@ export function AdminPage() {
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
{/* ── LOCAL ACCOUNTS section ──────────────────────────────────────── */}
|
||||
<section aria-label="Local Accounts" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
||||
<div style={sectionLabelStyle}>Local Accounts</div>
|
||||
|
||||
{/* Surface 11A — Add member inline form */}
|
||||
</div>{/* end admin-panel-settings */}
|
||||
|
||||
</div>{/* end centered content column */}
|
||||
|
||||
{/* ── Success toast (D-08) ──────────────────────────────────────────────── */}
|
||||
{toast && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
style={{
|
||||
border: '1px solid var(--color-border-subtle, var(--color-border))',
|
||||
borderRadius: '8px',
|
||||
padding: 'var(--space-4, 16px)',
|
||||
marginBottom: 'var(--space-6, 24px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-4, 16px)',
|
||||
}}
|
||||
>
|
||||
Add member
|
||||
</div>
|
||||
|
||||
{/* Display name */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-display-name"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Display name
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-display-name"
|
||||
type="text"
|
||||
value={createDisplayName}
|
||||
onChange={(e) => setCreateDisplayName(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
position: 'fixed',
|
||||
bottom: phone
|
||||
? 'calc(var(--bottom-chrome-h) + var(--space-4, 16px))'
|
||||
: 'var(--space-6, 24px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 300,
|
||||
background: 'var(--color-surface-raised, #ffffff)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-username"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-username"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
value={createUsername}
|
||||
onChange={(e) => setCreateUsername(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: 'var(--space-2, 8px)',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Initial password */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Initial password
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={createPassword}
|
||||
onChange={(e) => setCreatePassword(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirm password */}
|
||||
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-confirm-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={createConfirmPassword}
|
||||
onChange={(e) => setCreateConfirmPassword(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inline error */}
|
||||
{createError && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-destructive)',
|
||||
marginBottom: 'var(--space-3, 12px)',
|
||||
}}
|
||||
>
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action row */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={createSubmitDisabled}
|
||||
onClick={() => {
|
||||
setCreateError(null);
|
||||
createMemberMutation.mutate();
|
||||
}}
|
||||
style={{
|
||||
background: createSubmitDisabled
|
||||
? 'var(--color-border, #e2e4e9)'
|
||||
: 'var(--color-member-0, #4a90d9)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
cursor: createSubmitDisabled ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-6, 24px)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
transition: 'background 0.15s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2, 8px)',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 'var(--text-label-weight, 400)' as React.CSSProperties['fontWeight'],
|
||||
lineHeight: 'var(--text-label-line-height, 1.4)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
color: 'var(--color-text-primary)',
|
||||
whiteSpace: 'nowrap' as React.CSSProperties['whiteSpace'],
|
||||
maxWidth: '90vw',
|
||||
}}
|
||||
>
|
||||
{createMemberMutation.isPending && (
|
||||
<Loader2
|
||||
size={14}
|
||||
<CheckCircle
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
|
||||
style={{ color: 'var(--color-member-0)', flexShrink: 0 }}
|
||||
/>
|
||||
<span>{toast}</span>
|
||||
</div>
|
||||
)}
|
||||
Add member
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Credential sheet — admin-rotate or admin-add */}
|
||||
{sheetMember && (
|
||||
@@ -937,6 +1087,7 @@ export function AdminPage() {
|
||||
resetTriggerRef.current.focus();
|
||||
}
|
||||
}}
|
||||
onSuccess={() => setToast('Password reset.')}
|
||||
member={resetTargetMember}
|
||||
/>
|
||||
)}
|
||||
@@ -1189,10 +1340,11 @@ function CalendarRadioRow({ calendar, isSelected, onSelect }: CalendarRadioRowPr
|
||||
interface ResetPasswordSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
member: AdminMember;
|
||||
}
|
||||
|
||||
function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps) {
|
||||
function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswordSheetProps) {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -1222,6 +1374,10 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Phone detection for desktop centering
|
||||
const sheetPhone =
|
||||
typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (newPassword !== confirmPassword) throw new Error('mismatch');
|
||||
@@ -1229,6 +1385,7 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps
|
||||
},
|
||||
onSuccess: () => {
|
||||
handleClose();
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = err instanceof Error ? err.message : 'server';
|
||||
@@ -1259,12 +1416,14 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
{/* Sheet — phone: bottom-sheet / desktop: centered modal (D-09) */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Reset password"
|
||||
style={{
|
||||
style={
|
||||
sheetPhone
|
||||
? {
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
@@ -1275,9 +1434,24 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps
|
||||
padding: 'var(--space-6, 24px)',
|
||||
zIndex: 301,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}
|
||||
: {
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
maxWidth: '480px',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
width: 'calc(100% - var(--space-8, 32px))',
|
||||
maxHeight: 'calc(100dvh - var(--space-8, 32px))',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--color-surface, #ffffff)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
|
||||
padding: 'var(--space-6, 24px)',
|
||||
zIndex: 301,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}
|
||||
}
|
||||
>
|
||||
<h2
|
||||
ref={headingRef}
|
||||
|
||||
Reference in New Issue
Block a user