v0.10.7 fixed UI settings
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-backend",
|
"name": "jama-backend",
|
||||||
"version": "0.10.6",
|
"version": "0.10.7",
|
||||||
"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.6}"
|
VERSION="${1:-0.10.7}"
|
||||||
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.6",
|
"version": "0.10.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
|
import UserFooter from './UserFooter.jsx';
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -335,7 +336,7 @@ function KeyEntry({ onSubmit }) {
|
|||||||
|
|
||||||
// ── Main HostPanel ────────────────────────────────────────────────────────────
|
// ── Main HostPanel ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function HostPanel() {
|
export default function HostPanel({ onProfile, onHelp, onAbout }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('jama-host-key') || '');
|
const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('jama-host-key') || '');
|
||||||
const [status, setStatus] = useState(null);
|
const [status, setStatus] = useState(null);
|
||||||
@@ -486,6 +487,11 @@ export default function HostPanel() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* User footer */}
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ export default function Chat() {
|
|||||||
<div className="chat-layout">
|
<div className="chat-layout">
|
||||||
<GlobalBar isMobile={isMobile} showSidebar={true} onBurger={() => setDrawerOpen(true)} />
|
<GlobalBar isMobile={isMobile} showSidebar={true} onBurger={() => setDrawerOpen(true)} />
|
||||||
<div className="chat-body" style={{ overflow: 'hidden' }}>
|
<div className="chat-body" style={{ overflow: 'hidden' }}>
|
||||||
<UserManagerPage />
|
<UserManagerPage isMobile={isMobile} onProfile={() => setModal('profile')} onHelp={() => setModal('help')} onAbout={() => setModal('about')} />
|
||||||
</div>
|
</div>
|
||||||
<NavDrawer
|
<NavDrawer
|
||||||
open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
@@ -362,7 +362,7 @@ export default function Chat() {
|
|||||||
<div className="chat-layout">
|
<div className="chat-layout">
|
||||||
<GlobalBar isMobile={isMobile} showSidebar={true} onBurger={() => setDrawerOpen(true)} />
|
<GlobalBar isMobile={isMobile} showSidebar={true} onBurger={() => setDrawerOpen(true)} />
|
||||||
<div className="chat-body" style={{ overflow: 'hidden' }}>
|
<div className="chat-body" style={{ overflow: 'hidden' }}>
|
||||||
<GroupManagerPage />
|
<GroupManagerPage isMobile={isMobile} onProfile={() => setModal('profile')} onHelp={() => setModal('help')} onAbout={() => setModal('about')} />
|
||||||
</div>
|
</div>
|
||||||
<NavDrawer
|
<NavDrawer
|
||||||
open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
@@ -386,7 +386,7 @@ export default function Chat() {
|
|||||||
<div className="chat-layout">
|
<div className="chat-layout">
|
||||||
<GlobalBar isMobile={isMobile} showSidebar={true} onBurger={() => setDrawerOpen(true)} />
|
<GlobalBar isMobile={isMobile} showSidebar={true} onBurger={() => setDrawerOpen(true)} />
|
||||||
<div className="chat-body" style={{ overflow: 'hidden' }}>
|
<div className="chat-body" style={{ overflow: 'hidden' }}>
|
||||||
<HostPanel />
|
<HostPanel onProfile={() => setModal('profile')} onHelp={() => setModal('help')} onAbout={() => setModal('about')} />
|
||||||
</div>
|
</div>
|
||||||
<NavDrawer
|
<NavDrawer
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import UserFooter from '../components/UserFooter.jsx';
|
||||||
import { api } from '../utils/api.js';
|
import { api } from '../utils/api.js';
|
||||||
import { useToast } from '../contexts/ToastContext.jsx';
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
import Avatar from '../components/Avatar.jsx';
|
import Avatar from '../components/Avatar.jsx';
|
||||||
@@ -45,7 +46,7 @@ function GroupCheckList({ allGroups, selectedIds, onChange }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── All Groups tab ────────────────────────────────────────────────────────────
|
// ── All Groups tab ────────────────────────────────────────────────────────────
|
||||||
function AllGroupsTab({ allUsers, onRefresh }) {
|
function AllGroupsTab({ allUsers, onRefresh, isMobile = false }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
@@ -100,9 +101,9 @@ function AllGroupsTab({ allUsers, onRefresh }) {
|
|||||||
const isCreating = !selected;
|
const isCreating = !selected;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display:'flex', gap:0, height:'100%', minHeight:0 }}>
|
<div style={{ display:'flex', flexDirection: isMobile ? 'column' : 'row', gap:0, height:'100%', minHeight:0, overflow: isMobile ? 'auto' : 'hidden' }}>
|
||||||
{/* Sidebar list */}
|
{/* Sidebar list */}
|
||||||
<div style={{ width:220, flexShrink:0, borderRight:'1px solid var(--border)', overflowY:'auto', padding:'12px 8px' }}>
|
<div style={{ width: isMobile ? '100%' : 220, flexShrink:0, borderRight: isMobile ? 'none' : '1px solid var(--border)', borderBottom: isMobile ? '1px solid var(--border)' : 'none', overflowY: isMobile ? 'visible' : 'auto', padding:'12px 8px' }}>
|
||||||
<div style={{ fontSize:11, fontWeight:700, letterSpacing:'0.8px', textTransform:'uppercase', color:'var(--text-tertiary)', marginBottom:8, paddingLeft:4 }}>User Groups</div>
|
<div style={{ fontSize:11, fontWeight:700, letterSpacing:'0.8px', textTransform:'uppercase', color:'var(--text-tertiary)', marginBottom:8, paddingLeft:4 }}>User Groups</div>
|
||||||
<button onClick={clearSelection} style={{ display:'block', width:'100%', textAlign:'left', padding:'8px 10px', borderRadius:'var(--radius)', border:'none',
|
<button onClick={clearSelection} style={{ display:'block', width:'100%', textAlign:'left', padding:'8px 10px', borderRadius:'var(--radius)', border:'none',
|
||||||
background:isCreating?'var(--primary-light)':'transparent', color:isCreating?'var(--primary)':'var(--text-secondary)',
|
background:isCreating?'var(--primary-light)':'transparent', color:isCreating?'var(--primary)':'var(--text-secondary)',
|
||||||
@@ -121,8 +122,8 @@ function AllGroupsTab({ allUsers, onRefresh }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<div style={{ flex:1, overflowY:'auto', padding:'16px 24px' }}>
|
<div style={{ flex:1, overflowY: isMobile ? 'visible' : 'auto', padding: isMobile ? '16px 12px' : '16px 24px' }}>
|
||||||
<div style={{ display:'flex', flexDirection:'column', gap:18, maxWidth:520 }}>
|
<div style={{ display:'flex', flexDirection:'column', gap:18, maxWidth: isMobile ? '100%' : 520 }}>
|
||||||
<div>
|
<div>
|
||||||
<label className="settings-section-label">Group Name</label>
|
<label className="settings-section-label">Group Name</label>
|
||||||
<input className="input" value={editName} onChange={e => setEditName(e.target.value)}
|
<input className="input" value={editName} onChange={e => setEditName(e.target.value)}
|
||||||
@@ -159,7 +160,7 @@ function AllGroupsTab({ allUsers, onRefresh }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Direct Messages tab ───────────────────────────────────────────────────────
|
// ── Direct Messages tab ───────────────────────────────────────────────────────
|
||||||
function DirectMessagesTab({ allUserGroups, onRefresh, refreshKey }) {
|
function DirectMessagesTab({ allUserGroups, onRefresh, refreshKey, isMobile = false }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [dms, setDms] = useState([]);
|
const [dms, setDms] = useState([]);
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
@@ -212,8 +213,8 @@ function DirectMessagesTab({ allUserGroups, onRefresh, refreshKey }) {
|
|||||||
const isCreating = !selected;
|
const isCreating = !selected;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display:'flex', gap:0, height:'100%', minHeight:0 }}>
|
<div style={{ display:'flex', flexDirection: isMobile ? 'column' : 'row', gap:0, height:'100%', minHeight:0, overflow: isMobile ? 'auto' : 'hidden' }}>
|
||||||
<div style={{ width:220, flexShrink:0, borderRight:'1px solid var(--border)', overflowY:'auto', padding:'12px 8px' }}>
|
<div style={{ width: isMobile ? '100%' : 220, flexShrink:0, borderRight: isMobile ? 'none' : '1px solid var(--border)', borderBottom: isMobile ? '1px solid var(--border)' : 'none', overflowY: isMobile ? 'visible' : 'auto', padding:'12px 8px' }}>
|
||||||
<div style={{ fontSize:11, fontWeight:700, letterSpacing:'0.8px', textTransform:'uppercase', color:'var(--text-tertiary)', marginBottom:8, paddingLeft:4 }}>Multi-Group DMs</div>
|
<div style={{ fontSize:11, fontWeight:700, letterSpacing:'0.8px', textTransform:'uppercase', color:'var(--text-tertiary)', marginBottom:8, paddingLeft:4 }}>Multi-Group DMs</div>
|
||||||
<button onClick={clearSelection} style={{ display:'block', width:'100%', textAlign:'left', padding:'8px 10px', borderRadius:'var(--radius)', border:'none',
|
<button onClick={clearSelection} style={{ display:'block', width:'100%', textAlign:'left', padding:'8px 10px', borderRadius:'var(--radius)', border:'none',
|
||||||
background:isCreating?'var(--primary-light)':'transparent', color:isCreating?'var(--primary)':'var(--text-secondary)',
|
background:isCreating?'var(--primary-light)':'transparent', color:isCreating?'var(--primary)':'var(--text-secondary)',
|
||||||
@@ -230,8 +231,8 @@ function DirectMessagesTab({ allUserGroups, onRefresh, refreshKey }) {
|
|||||||
))}
|
))}
|
||||||
{dms.length===0 && <div style={{ fontSize:13, color:'var(--text-tertiary)', padding:'8px 4px' }}>No multi-group DMs yet</div>}
|
{dms.length===0 && <div style={{ fontSize:13, color:'var(--text-tertiary)', padding:'8px 4px' }}>No multi-group DMs yet</div>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex:1, overflowY:'auto', padding:'16px 24px' }}>
|
<div style={{ flex:1, overflowY: isMobile ? 'visible' : 'auto', padding: isMobile ? '16px 12px' : '16px 24px' }}>
|
||||||
<div style={{ display:'flex', flexDirection:'column', gap:18, maxWidth:520 }}>
|
<div style={{ display:'flex', flexDirection:'column', gap:18, maxWidth: isMobile ? '100%' : 520 }}>
|
||||||
<div>
|
<div>
|
||||||
<label className="settings-section-label">DM Name</label>
|
<label className="settings-section-label">DM Name</label>
|
||||||
<input className="input" value={dmName} onChange={e => setDmName(e.target.value)} placeholder="e.g. Coaches + Players" style={{ marginTop:6 }} autoComplete="new-password" />
|
<input className="input" value={dmName} onChange={e => setDmName(e.target.value)} placeholder="e.g. Coaches + Players" style={{ marginTop:6 }} autoComplete="new-password" />
|
||||||
@@ -268,7 +269,7 @@ function DirectMessagesTab({ allUserGroups, onRefresh, refreshKey }) {
|
|||||||
|
|
||||||
|
|
||||||
// ── U2U Restrictions tab ──────────────────────────────────────────────────────
|
// ── U2U Restrictions tab ──────────────────────────────────────────────────────
|
||||||
function U2URestrictionsTab({ allUserGroups }) {
|
function U2URestrictionsTab({ allUserGroups, isMobile = false }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [selectedGroup, setSelectedGroup] = useState(null);
|
const [selectedGroup, setSelectedGroup] = useState(null);
|
||||||
const [blockedIds, setBlockedIds] = useState(new Set());
|
const [blockedIds, setBlockedIds] = useState(new Set());
|
||||||
@@ -276,6 +277,22 @@ function U2URestrictionsTab({ allUserGroups }) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
// Map of groupId → number of restrictions (for showing dots in sidebar)
|
||||||
|
const [restrictionCounts, setRestrictionCounts] = useState({});
|
||||||
|
|
||||||
|
// Load restriction counts for all groups on mount and after saves
|
||||||
|
const loadAllCounts = useCallback(async () => {
|
||||||
|
const counts = {};
|
||||||
|
for (const g of allUserGroups) {
|
||||||
|
try {
|
||||||
|
const { blockedGroupIds } = await api.getGroupRestrictions(g.id);
|
||||||
|
counts[g.id] = blockedGroupIds.length;
|
||||||
|
} catch { counts[g.id] = 0; }
|
||||||
|
}
|
||||||
|
setRestrictionCounts(counts);
|
||||||
|
}, [allUserGroups]);
|
||||||
|
|
||||||
|
useEffect(() => { if (allUserGroups.length > 0) loadAllCounts(); }, [allUserGroups]);
|
||||||
|
|
||||||
const loadRestrictions = async (group) => {
|
const loadRestrictions = async (group) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -314,6 +331,7 @@ function U2URestrictionsTab({ allUserGroups }) {
|
|||||||
await api.setGroupRestrictions(selectedGroup.id, [...blockedIds]);
|
await api.setGroupRestrictions(selectedGroup.id, [...blockedIds]);
|
||||||
setSavedBlockedIds(new Set(blockedIds));
|
setSavedBlockedIds(new Set(blockedIds));
|
||||||
toast('Restrictions saved', 'success');
|
toast('Restrictions saved', 'success');
|
||||||
|
loadAllCounts();
|
||||||
} catch (e) { toast(e.message, 'error'); }
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
@@ -328,14 +346,14 @@ function U2URestrictionsTab({ allUserGroups }) {
|
|||||||
: otherGroups;
|
: otherGroups;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display:'flex', gap:0, height:'100%', minHeight:0 }}>
|
<div style={{ display:'flex', flexDirection: isMobile ? 'column' : 'row', gap:0, height:'100%', minHeight:0, overflow: isMobile ? 'auto' : 'hidden' }}>
|
||||||
{/* Group selector sidebar */}
|
{/* Group selector sidebar */}
|
||||||
<div style={{ width:220, flexShrink:0, borderRight:'1px solid var(--border)', overflowY:'auto', padding:'12px 8px' }}>
|
<div style={{ width: isMobile ? '100%' : 220, flexShrink:0, borderRight: isMobile ? 'none' : '1px solid var(--border)', borderBottom: isMobile ? '1px solid var(--border)' : 'none', overflowY: isMobile ? 'visible' : 'auto', padding:'12px 8px' }}>
|
||||||
<div style={{ fontSize:11, fontWeight:700, letterSpacing:'0.8px', textTransform:'uppercase', color:'var(--text-tertiary)', marginBottom:8, paddingLeft:4 }}>
|
<div style={{ fontSize:11, fontWeight:700, letterSpacing:'0.8px', textTransform:'uppercase', color:'var(--text-tertiary)', marginBottom:8, paddingLeft:4 }}>
|
||||||
Select Group
|
Select Group
|
||||||
</div>
|
</div>
|
||||||
{allUserGroups.map(g => {
|
{allUserGroups.map(g => {
|
||||||
const hasRestrictions = g.id === selectedGroup?.id ? blockedIds.size > 0 : false;
|
const hasRestrictions = g.id === selectedGroup?.id ? blockedIds.size > 0 : (restrictionCounts[g.id] || 0) > 0;
|
||||||
return (
|
return (
|
||||||
<button key={g.id} onClick={() => selectGroup(g)} style={{
|
<button key={g.id} onClick={() => selectGroup(g)} style={{
|
||||||
display:'block', width:'100%', textAlign:'left', padding:'8px 10px',
|
display:'block', width:'100%', textAlign:'left', padding:'8px 10px',
|
||||||
@@ -351,7 +369,7 @@ function U2URestrictionsTab({ allUserGroups }) {
|
|||||||
<div style={{ fontSize:11, color:'var(--text-tertiary)' }}>{g.member_count} member{g.member_count!==1?'s':''}</div>
|
<div style={{ fontSize:11, color:'var(--text-tertiary)' }}>{g.member_count} member{g.member_count!==1?'s':''}</div>
|
||||||
</div>
|
</div>
|
||||||
{hasRestrictions && (
|
{hasRestrictions && (
|
||||||
<span style={{ width:8, height:8, borderRadius:'50%', background:'var(--warning)', flexShrink:0 }} title="Has restrictions" />
|
<span style={{ width:8, height:8, borderRadius:'50%', background:'var(--error)', flexShrink:0 }} title="Has U2U restrictions" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -363,7 +381,7 @@ function U2URestrictionsTab({ allUserGroups }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Restriction editor */}
|
{/* Restriction editor */}
|
||||||
<div style={{ flex:1, overflowY:'auto', padding:'16px 24px' }}>
|
<div style={{ flex:1, overflowY: isMobile ? 'visible' : 'auto', padding: isMobile ? '16px 12px' : '16px 24px' }}>
|
||||||
{!selectedGroup ? (
|
{!selectedGroup ? (
|
||||||
<div style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center',
|
<div style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center',
|
||||||
height:'100%', color:'var(--text-tertiary)', gap:12, textAlign:'center' }}>
|
height:'100%', color:'var(--text-tertiary)', gap:12, textAlign:'center' }}>
|
||||||
@@ -376,7 +394,7 @@ function U2URestrictionsTab({ allUserGroups }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ maxWidth:540 }}>
|
<div style={{ maxWidth: isMobile ? '100%' : 540 }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ marginBottom:20 }}>
|
<div style={{ marginBottom:20 }}>
|
||||||
<h3 style={{ fontSize:16, fontWeight:700, margin:'0 0 6px' }}>{selectedGroup.name}</h3>
|
<h3 style={{ fontSize:16, fontWeight:700, margin:'0 0 6px' }}>{selectedGroup.name}</h3>
|
||||||
@@ -477,7 +495,7 @@ function U2URestrictionsTab({ allUserGroups }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
export default function GroupManagerPage() {
|
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([]);
|
||||||
const [allUserGroups, setAllUserGroups] = useState([]);
|
const [allUserGroups, setAllUserGroups] = useState([]);
|
||||||
@@ -499,18 +517,22 @@ export default function GroupManagerPage() {
|
|||||||
<span style={{ fontWeight:700, fontSize:15 }}>Group Manager</span>
|
<span style={{ fontWeight:700, fontSize:15 }}>Group Manager</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display:'flex', gap:8 }}>
|
<div style={{ display:'flex', gap:8 }}>
|
||||||
<button className={`btn btn-sm ${tab==='all'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('all')}>User Groups</button>
|
<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')}>Multi-Group DMs</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 Restrictions</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:'hidden' }}>
|
<div style={{ flex:1, display:'flex', overflow: isMobile ? 'auto' : 'hidden' }}>
|
||||||
{tab==='all' && <AllGroupsTab allUsers={allUsers} onRefresh={onRefresh} />}
|
{tab==='all' && <AllGroupsTab allUsers={allUsers} onRefresh={onRefresh} isMobile={isMobile} />}
|
||||||
{tab==='dm' && <DirectMessagesTab allUserGroups={allUserGroups} onRefresh={onRefresh} refreshKey={refreshKey} />}
|
{tab==='dm' && <DirectMessagesTab allUserGroups={allUserGroups} onRefresh={onRefresh} refreshKey={refreshKey} isMobile={isMobile} />}
|
||||||
{tab==='u2u' && <U2URestrictionsTab allUserGroups={allUserGroups} />}
|
{tab==='u2u' && <U2URestrictionsTab allUserGroups={allUserGroups} isMobile={isMobile} />}
|
||||||
|
</div>
|
||||||
|
{/* User footer */}
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { useToast } from '../contexts/ToastContext.jsx';
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
import { api } from '../utils/api.js';
|
import { api } from '../utils/api.js';
|
||||||
import Avatar from '../components/Avatar.jsx';
|
import Avatar from '../components/Avatar.jsx';
|
||||||
|
import UserFooter from '../components/UserFooter.jsx';
|
||||||
|
|
||||||
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
|
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
|
||||||
|
|
||||||
@@ -161,7 +162,7 @@ function UserRow({ u, onUpdated }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Create user form ──────────────────────────────────────────────────────────
|
// ── Create user form ──────────────────────────────────────────────────────────
|
||||||
function CreateUserForm({ userPass, onCreated }) {
|
function CreateUserForm({ userPass, onCreated, isMobile = false }) {
|
||||||
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);
|
||||||
@@ -182,8 +183,8 @@ function CreateUserForm({ userPass, onCreated }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth:560 }}>
|
<div style={{ maxWidth: isMobile ? '100%' : 560 }}>
|
||||||
<div style={{ display:'grid', gridTemplateColumns:'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>
|
||||||
<input className="input" placeholder="Jane Smith" autoComplete="new-password" autoCorrect="off" autoCapitalize="words" value={form.name} onChange={e => set('name')(e.target.value)} />
|
<input className="input" placeholder="Jane Smith" autoComplete="new-password" autoCorrect="off" autoCapitalize="words" value={form.name} onChange={e => set('name')(e.target.value)} />
|
||||||
@@ -292,8 +293,7 @@ function BulkImportForm({ userPass, onCreated }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
export default function UserManagerPage() {
|
export default function UserManagerPage({ isMobile = false, onProfile, onHelp, onAbout }) {
|
||||||
const isMobile = window.innerWidth < 768;
|
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
@@ -321,31 +321,32 @@ export default function UserManagerPage() {
|
|||||||
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={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', background:'var(--background)' }}>
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
<div style={{ background:'var(--surface)', borderBottom:'1px solid var(--border)', padding:'0 24px', flexShrink:0 }}>
|
<div style={{ background:'var(--surface)', borderBottom:'1px solid var(--border)', padding:`0 ${pad}px`, flexShrink:0 }}>
|
||||||
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', height:52 }}>
|
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', height:52, gap:8 }}>
|
||||||
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
|
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
|
||||||
<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>
|
<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>
|
<span style={{ fontWeight:700, fontSize:15 }}>User Manager</span>
|
||||||
{!loading && <span style={{ fontSize:12, color:'var(--text-tertiary)' }}>{users.length} user{users.length!==1?'s':''}</span>}
|
{!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 }}>
|
<div style={{ display:'flex', gap:8 }}>
|
||||||
<button className={`btn btn-sm ${tab==='users'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('users')}>All Users</button>
|
<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>
|
<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 Import</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:24 }}>
|
<div style={{ flex:1, overflowY:'auto', padding:pad }}>
|
||||||
{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}
|
autoComplete="new-password" autoCorrect="off" spellCheck={false}
|
||||||
style={{ marginBottom:16, maxWidth:400 }} />
|
style={{ marginBottom:16, width:'100%', maxWidth: isMobile ? '100%' : 400 }} />
|
||||||
<div style={{ background:'var(--surface)', borderRadius:'var(--radius)', boxShadow:'var(--shadow-sm)', overflow:'hidden' }}>
|
<div style={{ background:'var(--surface)', borderRadius:'var(--radius)', boxShadow:'var(--shadow-sm)', overflow:'hidden' }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ padding:48, textAlign:'center' }}><div className="spinner" /></div>
|
<div style={{ padding:48, textAlign:'center' }}><div className="spinner" /></div>
|
||||||
@@ -364,9 +365,14 @@ export default function UserManagerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{tab === 'create' && <CreateUserForm userPass={userPass} onCreated={() => { load(); setTab('users'); }} />}
|
{tab === 'create' && <CreateUserForm userPass={userPass} onCreated={() => { load(); setTab('users'); }} isMobile={isMobile} />}
|
||||||
{tab === 'bulk' && <BulkImportForm userPass={userPass} onCreated={load} />}
|
{tab === 'bulk' && <BulkImportForm userPass={userPass} onCreated={load} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* User footer */}
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user