v0.10.8 mobile bug fixes
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-backend",
|
"name": "jama-backend",
|
||||||
"version": "0.10.7",
|
"version": "0.10.8",
|
||||||
"description": "TeamChat backend server",
|
"description": "TeamChat backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.10.7}"
|
VERSION="${1:-0.10.8}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="jama"
|
IMAGE_NAME="jama"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-frontend",
|
"name": "jama-frontend",
|
||||||
"version": "0.10.7",
|
"version": "0.10.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -495,6 +495,8 @@ function U2URestrictionsTab({ allUserGroups, isMobile = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
|
const SIDEBAR_W = 260;
|
||||||
|
|
||||||
export default function GroupManagerPage({ isMobile = false, onProfile, onHelp, onAbout }) {
|
export default function GroupManagerPage({ isMobile = false, onProfile, onHelp, onAbout }) {
|
||||||
const [tab, setTab] = useState('all');
|
const [tab, setTab] = useState('all');
|
||||||
const [allUsers, setAllUsers] = useState([]);
|
const [allUsers, setAllUsers] = useState([]);
|
||||||
@@ -508,31 +510,47 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp,
|
|||||||
}, [refreshKey]);
|
}, [refreshKey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', background:'var(--background)' }}>
|
<div style={{ display:'flex', flex:1, overflow:'hidden', minHeight:0 }}>
|
||||||
{/* Page header */}
|
|
||||||
<div style={{ background:'var(--surface)', borderBottom:'1px solid var(--border)', padding:'0 24px', flexShrink:0 }}>
|
{/* ── Left panel (desktop only) ── */}
|
||||||
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', height:52 }}>
|
{!isMobile && (
|
||||||
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
|
<div style={{ width:SIDEBAR_W, flexShrink:0, borderRight:'1px solid var(--border)', display:'flex', flexDirection:'column', background:'var(--surface)', overflow:'hidden' }}>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>
|
<div style={{ flex:1 }} />
|
||||||
<span style={{ fontWeight:700, fontSize:15 }}>Group Manager</span>
|
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display:'flex', gap:8 }}>
|
)}
|
||||||
<button className={`btn btn-sm ${tab==='all'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('all')}>{isMobile ? 'Groups' : 'User Groups'}</button>
|
|
||||||
<button className={`btn btn-sm ${tab==='dm'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('dm')}>{isMobile ? 'Multi-DMs' : 'Multi-Group DMs'}</button>
|
{/* ── Right panel ── */}
|
||||||
<button className={`btn btn-sm ${tab==='u2u'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('u2u')}>U2U</button>
|
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minWidth:0, background:'var(--background)' }}>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ background:'var(--surface)', borderBottom:'1px solid var(--border)', padding:'0 16px', flexShrink:0 }}>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', height:52, gap:8 }}>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>
|
||||||
|
<span style={{ fontWeight:700, fontSize:15 }}>Group Manager</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display:'flex', gap:6, flexShrink:0 }}>
|
||||||
|
<button className={`btn btn-sm ${tab==='all'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('all')}>{isMobile ? 'Groups' : 'User Groups'}</button>
|
||||||
|
<button className={`btn btn-sm ${tab==='dm'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('dm')}>{isMobile ? 'Multi-DMs' : 'Multi-Group DMs'}</button>
|
||||||
|
<button className={`btn btn-sm ${tab==='u2u'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('u2u')}>U2U</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div style={{ flex:1, display:'flex', overflow: isMobile ? 'auto' : 'hidden' }}>
|
<div style={{ flex:1, display:'flex', overflow: isMobile ? 'auto' : 'hidden', paddingBottom: isMobile ? 70 : 0 }}>
|
||||||
{tab==='all' && <AllGroupsTab allUsers={allUsers} onRefresh={onRefresh} isMobile={isMobile} />}
|
{tab==='all' && <AllGroupsTab allUsers={allUsers} onRefresh={onRefresh} isMobile={isMobile} />}
|
||||||
{tab==='dm' && <DirectMessagesTab allUserGroups={allUserGroups} onRefresh={onRefresh} refreshKey={refreshKey} isMobile={isMobile} />}
|
{tab==='dm' && <DirectMessagesTab allUserGroups={allUserGroups} onRefresh={onRefresh} refreshKey={refreshKey} isMobile={isMobile} />}
|
||||||
{tab==='u2u' && <U2URestrictionsTab allUserGroups={allUserGroups} isMobile={isMobile} />}
|
{tab==='u2u' && <U2URestrictionsTab allUserGroups={allUserGroups} isMobile={isMobile} />}
|
||||||
</div>
|
</div>
|
||||||
{/* User footer */}
|
|
||||||
<div className="sidebar-footer">
|
{/* Mobile footer — fixed at bottom, always visible */}
|
||||||
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
|
{isMobile && (
|
||||||
|
<div style={{ position:'fixed', bottom:0, left:0, right:0, zIndex:20, background:'var(--surface)', borderTop:'1px solid var(--border)' }}>
|
||||||
|
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { api } from '../utils/api.js';
|
|||||||
import Avatar from '../components/Avatar.jsx';
|
import Avatar from '../components/Avatar.jsx';
|
||||||
import UserFooter from '../components/UserFooter.jsx';
|
import UserFooter from '../components/UserFooter.jsx';
|
||||||
|
|
||||||
|
const SIDEBAR_W = 260;
|
||||||
|
|
||||||
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
|
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
|
||||||
|
|
||||||
function parseCSV(text) {
|
function parseCSV(text) {
|
||||||
@@ -118,7 +120,6 @@ function UserRow({ u, onUpdated }) {
|
|||||||
Edit Name
|
Edit Name
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display:'flex', flexDirection:'column', alignItems:'flex-start', gap:4 }}>
|
<div style={{ display:'flex', flexDirection:'column', alignItems:'flex-start', gap:4 }}>
|
||||||
<select value={roleWarning ? '' : u.role} onChange={e => handleRole(e.target.value)}
|
<select value={roleWarning ? '' : u.role} onChange={e => handleRole(e.target.value)}
|
||||||
className="input" style={{ width:140, padding:'5px 8px', fontSize:13, borderColor:roleWarning?'#e53935':undefined }}>
|
className="input" style={{ width:140, padding:'5px 8px', fontSize:13, borderColor:roleWarning?'#e53935':undefined }}>
|
||||||
@@ -128,7 +129,6 @@ function UserRow({ u, onUpdated }) {
|
|||||||
</select>
|
</select>
|
||||||
{roleWarning && <span style={{ fontSize:12, color:'#e53935' }}>Role Required</span>}
|
{roleWarning && <span style={{ fontSize:12, color:'#e53935' }}>Role Required</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showReset ? (
|
{showReset ? (
|
||||||
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
|
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
|
||||||
<input className="input" style={{ flex:1, fontSize:13, padding:'5px 8px' }}
|
<input className="input" style={{ flex:1, fontSize:13, padding:'5px 8px' }}
|
||||||
@@ -146,7 +146,6 @@ function UserRow({ u, onUpdated }) {
|
|||||||
Reset Password
|
Reset Password
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
|
<div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
|
||||||
{u.status==='active' ? (
|
{u.status==='active' ? (
|
||||||
<button className="btn btn-secondary btn-sm" onClick={handleSuspend}>Suspend</button>
|
<button className="btn btn-secondary btn-sm" onClick={handleSuspend}>Suspend</button>
|
||||||
@@ -162,7 +161,7 @@ function UserRow({ u, onUpdated }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Create user form ──────────────────────────────────────────────────────────
|
// ── Create user form ──────────────────────────────────────────────────────────
|
||||||
function CreateUserForm({ userPass, onCreated, isMobile = false }) {
|
function CreateUserForm({ userPass, onCreated, isMobile }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [form, setForm] = useState({ name:'', email:'', password:'', role:'member' });
|
const [form, setForm] = useState({ name:'', email:'', password:'', role:'member' });
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -183,7 +182,7 @@ function CreateUserForm({ userPass, onCreated, isMobile = false }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: isMobile ? '100%' : 560 }}>
|
<div style={{ maxWidth:560 }}>
|
||||||
<div style={{ display:'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap:12, marginBottom:12 }}>
|
<div style={{ display:'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap:12, marginBottom:12 }}>
|
||||||
<div className="flex-col gap-1">
|
<div className="flex-col gap-1">
|
||||||
<label className="text-sm font-medium" style={{ color:'var(--text-secondary)' }}>Full Name <span style={{ fontWeight:400, color:'var(--text-tertiary)' }}>(First Last)</span></label>
|
<label className="text-sm font-medium" style={{ color:'var(--text-secondary)' }}>Full Name <span style={{ fontWeight:400, color:'var(--text-tertiary)' }}>(First Last)</span></label>
|
||||||
@@ -256,11 +255,7 @@ function BulkImportForm({ userPass, onCreated }) {
|
|||||||
<input ref={fileRef} type="file" accept=".csv,.txt" style={{ display:'none' }} onChange={handleFile} />
|
<input ref={fileRef} type="file" accept=".csv,.txt" style={{ display:'none' }} onChange={handleFile} />
|
||||||
</label>
|
</label>
|
||||||
{csvFile && <span className="text-sm" style={{ color:'var(--text-secondary)' }}>{csvFile.name}{csvRows.length > 0 && <span style={{ color:'var(--text-tertiary)', marginLeft:6 }}>({csvRows.length} valid)</span>}</span>}
|
{csvFile && <span className="text-sm" style={{ color:'var(--text-secondary)' }}>{csvFile.name}{csvRows.length > 0 && <span style={{ color:'var(--text-tertiary)', marginLeft:6 }}>({csvRows.length} valid)</span>}</span>}
|
||||||
{csvRows.length > 0 && (
|
{csvRows.length > 0 && <button className="btn btn-primary" onClick={handleImport} disabled={loading}>{loading ? 'Creating…' : `Create ${csvRows.length} User${csvRows.length!==1?'s':''}`}</button>}
|
||||||
<button className="btn btn-primary" onClick={handleImport} disabled={loading}>
|
|
||||||
{loading ? 'Creating…' : `Create ${csvRows.length} User${csvRows.length!==1?'s':''}`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{csvInvalid.length > 0 && (
|
{csvInvalid.length > 0 && (
|
||||||
<div style={{ background:'rgba(229,57,53,0.07)', border:'1px solid #e53935', borderRadius:'var(--radius)', padding:10 }}>
|
<div style={{ background:'rgba(229,57,53,0.07)', border:'1px solid #e53935', borderRadius:'var(--radius)', padding:10 }}>
|
||||||
@@ -300,6 +295,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [tab, setTab] = useState('users');
|
const [tab, setTab] = useState('users');
|
||||||
const [userPass, setUserPass] = useState('user@1234');
|
const [userPass, setUserPass] = useState('user@1234');
|
||||||
|
const [inputFocused, setInputFocused] = useState(false);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoadError(''); setLoading(true);
|
setLoadError(''); setLoading(true);
|
||||||
@@ -321,57 +317,72 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
|
|||||||
u.email?.toLowerCase().includes(search.toLowerCase())
|
u.email?.toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
const pad = isMobile ? 16 : 24;
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', background:'var(--background)' }}>
|
<div style={{ display:'flex', flex:1, overflow:'hidden', minHeight:0 }}>
|
||||||
{/* Page header */}
|
|
||||||
<div style={{ background:'var(--surface)', borderBottom:'1px solid var(--border)', padding:`0 ${pad}px`, flexShrink:0 }}>
|
{/* ── Left panel (desktop only) — blank, reserved for future use ── */}
|
||||||
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', height:52, gap:8 }}>
|
{!isMobile && (
|
||||||
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
|
<div style={{ width:SIDEBAR_W, flexShrink:0, borderRight:'1px solid var(--border)', display:'flex', flexDirection:'column', background:'var(--surface)', overflow:'hidden' }}>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
<div style={{ flex:1 }} />
|
||||||
<span style={{ fontWeight:700, fontSize:15 }}>User Manager</span>
|
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
|
||||||
{!loading && <span style={{ fontSize:12, color:'var(--text-tertiary)', flexShrink:0 }}>{users.length} user{users.length!==1?'s':''}</span>}
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div style={{ display:'flex', gap:8 }}>
|
|
||||||
<button className={`btn btn-sm ${tab==='users'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('users')}>Users</button>
|
{/* ── Right panel ── */}
|
||||||
<button className={`btn btn-sm ${tab==='create'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('create')}>+ Create</button>
|
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minWidth:0, background:'var(--background)' }}>
|
||||||
{!isMobile && <button className={`btn btn-sm ${tab==='bulk'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('bulk')}>Bulk</button>}
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ background:'var(--surface)', borderBottom:'1px solid var(--border)', padding:'0 16px', flexShrink:0 }}>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', height:52, gap:8 }}>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:8, minWidth:0 }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
<span style={{ fontWeight:700, fontSize:15 }}>User Manager</span>
|
||||||
|
{!loading && <span style={{ fontSize:12, color:'var(--text-tertiary)', flexShrink:0 }}>{users.length} user{users.length!==1?'s':''}</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display:'flex', gap:6, flexShrink:0 }}>
|
||||||
|
<button className={`btn btn-sm ${tab==='users'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('users')}>Users</button>
|
||||||
|
<button className={`btn btn-sm ${tab==='create'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('create')}>+ Create</button>
|
||||||
|
{!isMobile && <button className={`btn btn-sm ${tab==='bulk'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('bulk')}>Bulk</button>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div style={{ flex:1, overflowY:'auto', padding:pad }}>
|
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 86 : 16 }}>
|
||||||
{tab === 'users' && (
|
{tab === 'users' && (
|
||||||
<>
|
<>
|
||||||
<input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)}
|
<input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)}
|
||||||
autoComplete="new-password" autoCorrect="off" spellCheck={false}
|
onFocus={() => setInputFocused(true)} onBlur={() => setInputFocused(false)}
|
||||||
style={{ marginBottom:16, width:'100%', maxWidth: isMobile ? '100%' : 400 }} />
|
autoComplete="new-password" autoCorrect="off" spellCheck={false}
|
||||||
<div style={{ background:'var(--surface)', borderRadius:'var(--radius)', boxShadow:'var(--shadow-sm)', overflow:'hidden' }}>
|
style={{ marginBottom:16, width:'100%', maxWidth: isMobile ? '100%' : 400 }} />
|
||||||
{loading ? (
|
<div style={{ background:'var(--surface)', borderRadius:'var(--radius)', boxShadow:'var(--shadow-sm)', overflow:'hidden' }}>
|
||||||
<div style={{ padding:48, textAlign:'center' }}><div className="spinner" /></div>
|
{loading ? (
|
||||||
) : loadError ? (
|
<div style={{ padding:48, textAlign:'center' }}><div className="spinner" /></div>
|
||||||
<div style={{ padding:32, textAlign:'center', color:'var(--error)' }}>
|
) : loadError ? (
|
||||||
<div style={{ marginBottom:12 }}>⚠ {loadError}</div>
|
<div style={{ padding:32, textAlign:'center', color:'var(--error)' }}>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={load}>Retry</button>
|
<div style={{ marginBottom:12 }}>⚠ {loadError}</div>
|
||||||
</div>
|
<button className="btn btn-secondary btn-sm" onClick={load}>Retry</button>
|
||||||
) : filtered.length === 0 ? (
|
</div>
|
||||||
<div style={{ padding:32, textAlign:'center', color:'var(--text-tertiary)', fontSize:14 }}>
|
) : filtered.length === 0 ? (
|
||||||
{search ? 'No users match your search.' : 'No users yet.'}
|
<div style={{ padding:32, textAlign:'center', color:'var(--text-tertiary)', fontSize:14 }}>
|
||||||
</div>
|
{search ? 'No users match your search.' : 'No users yet.'}
|
||||||
) : (
|
</div>
|
||||||
filtered.map(u => <UserRow key={u.id} u={u} onUpdated={load} />)
|
) : (
|
||||||
)}
|
filtered.map(u => <UserRow key={u.id} u={u} onUpdated={load} />)
|
||||||
</div>
|
)}
|
||||||
</>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tab === 'create' && <CreateUserForm userPass={userPass} onCreated={() => { load(); setTab('users'); }} isMobile={isMobile} />}
|
||||||
|
{tab === 'bulk' && <BulkImportForm userPass={userPass} onCreated={load} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile footer — fixed at bottom, hidden when keyboard open */}
|
||||||
|
{isMobile && !inputFocused && (
|
||||||
|
<div style={{ position:'fixed', bottom:0, left:0, right:0, zIndex:20, background:'var(--surface)', borderTop:'1px solid var(--border)' }}>
|
||||||
|
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{tab === 'create' && <CreateUserForm userPass={userPass} onCreated={() => { load(); setTab('users'); }} isMobile={isMobile} />}
|
|
||||||
{tab === 'bulk' && <BulkImportForm userPass={userPass} onCreated={load} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User footer */}
|
|
||||||
<div className="sidebar-footer">
|
|
||||||
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user