import { useState, useEffect, useCallback } from 'react'; import UserFooter from '../components/UserFooter.jsx'; // ── useKeyboardOpen — true when software keyboard is visible ───────────────── function useKeyboardOpen() { const [open, setOpen] = useState(false); useEffect(() => { const vv = window.visualViewport; if (!vv) return; const handler = () => setOpen(vv.height < window.innerHeight * 0.75); vv.addEventListener('resize', handler); return () => vv.removeEventListener('resize', handler); }, []); return open; } import { api } from '../utils/api.js'; import { useToast } from '../contexts/ToastContext.jsx'; import Avatar from '../components/Avatar.jsx'; // ── Shared sub-components (identical logic to modal versions) ───────────────── function UserCheckList({ allUsers, selectedIds, onChange, onIF, onIB }) { const [search, setSearch] = useState(''); const filtered = allUsers .filter(u => (u.display_name||u.name).toLowerCase().includes(search.toLowerCase())) .sort((a, b) => (a.display_name||a.name).localeCompare(b.display_name||b.name)); return (
setSearch(e.target.value)} autoComplete="off" style={{ marginBottom:8 }} onFocus={onIF} onBlur={onIB} />
{filtered.map(u => ( ))} {filtered.length === 0 &&
No users found
}
); } function GroupCheckList({ allGroups, selectedIds, onChange }) { return (
{allGroups.map(g => ( ))} {allGroups.length === 0 &&
No user groups yet
}
); } // ── All Groups tab ──────────────────────────────────────────────────────────── function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) { const toast = useToast(); const [groups, setGroups] = useState([]); const [selected, setSelected] = useState(null); const [savedMembers, setSavedMembers] = useState(new Set()); const [members, setMembers] = useState(new Set()); const [fullMembers, setFullMembers] = useState([]); // full member objects including deleted const [editName, setEditName] = useState(''); const [noDm, setNoDm] = useState(false); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [showDelete, setShowDelete] = useState(false); const [accordionOpen, setAccordionOpen] = useState(false); const load = useCallback(() => api.getUserGroups().then(({ groups }) => setGroups([...(groups||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {}), []); useEffect(() => { load(); }, [load]); const selectGroup = async (g) => { setShowDelete(false); setAccordionOpen(false); const { members: mems } = await api.getUserGroup(g.id); const ids = new Set(mems.map(m => m.id)); setSelected(g); setEditName(g.name); setMembers(ids); setSavedMembers(ids); setFullMembers(mems); // No DM → checkbox enabled+checked; has DM → checkbox disabled+unchecked setNoDm(!g.dm_group_id); }; const clearSelection = () => { setSelected(null); setEditName(''); setMembers(new Set()); setSavedMembers(new Set()); setShowDelete(false); setFullMembers([]); setNoDm(false); }; const handleSave = async () => { if (!editName.trim()) return toast('Name required', 'error'); setSaving(true); try { if (selected) { // createDm=true when the group has no DM and the user unchecked "Do not create Group DM" const createDm = !selected.dm_group_id && !noDm; const { group: updated } = await api.updateUserGroup(selected.id, { name: editName.trim(), memberIds: [...members], createDm }); toast('Group updated', 'success'); const { members: fresh } = await api.getUserGroup(selected.id); const freshIds = new Set(fresh.map(m => m.id)); setSavedMembers(freshIds); setMembers(freshIds); setFullMembers(fresh); // Reflect new dm_group_id if a DM was just created setSelected(prev => ({ ...prev, name: editName.trim(), dm_group_id: updated?.dm_group_id ?? prev.dm_group_id })); if (createDm) setNoDm(false); } else { await api.createUserGroup({ name: editName.trim(), memberIds: [...members], noDm }); toast(`Group "${editName.trim()}" created`, 'success'); clearSelection(); } load(); onRefresh(); } catch(e) { toast(e.message, 'error'); } finally { setSaving(false); } }; const handleDelete = async () => { setDeleting(true); try { await api.deleteUserGroup(selected.id); toast('Group deleted', 'success'); clearSelection(); load(); onRefresh(); } catch(e) { toast(e.message, 'error'); } finally { setDeleting(false); } }; const canDelete = selected && savedMembers.size === 0; const isCreating = !selected; const deletedMembers = fullMembers.filter(m => m.status === 'deleted'); const forceRemoveMember = async (m) => { if (!confirm(`Force-remove deleted user "${m.name}" from this group?`)) return; try { await api.removeUserGroupMember(selected.id, m.id); toast(`${m.name} removed`, 'success'); const { members: fresh } = await api.getUserGroup(selected.id); const freshIds = new Set(fresh.map(x => x.id)); setSavedMembers(freshIds); setMembers(freshIds); setFullMembers(fresh); load(); onRefresh(); } catch(e) { toast(e.message, 'error'); } }; return (
{/* Sidebar — desktop only */} {!isMobile && (
User Groups
{groups.map(g => ( ))} {groups.length===0 &&
No groups yet
}
)} {/* Mobile accordion */} {isMobile && (
{accordionOpen && (
{groups.map(g => ( ))} {groups.length===0 &&
No groups yet
}
)}
)} {/* Form */}
setEditName(e.target.value)} autoComplete="off" placeholder="e.g. Coaches" style={{ marginTop:6 }} onFocus={onIF} onBlur={onIB} /> {isCreating && !noDm &&

A matching Direct Message group will be created automatically.

} {selected && selected.dm_group_id &&

Group DM already exists — cannot be removed.

}

{members.size} selected

{deletedMembers.length > 0 && (
{deletedMembers.map(m => (
{m.name} Deleted
))}

These users were deleted but remain as group members. Remove them to allow this group to be deleted.

)}
{!isCreating && } {!isCreating && ( )}
{showDelete && (

Delete {selected?.name}? This also deletes the associated direct message group.

)}
); } // ── Direct Messages tab ─────────────────────────────────────────────────────── function DirectMessagesTab({ allUserGroups, onRefresh, refreshKey, isMobile = false, onIF, onIB }) { const toast = useToast(); const [dms, setDms] = useState([]); const [selected, setSelected] = useState(null); const [savedGroupIds, setSavedGroupIds] = useState(new Set()); const [groupIds, setGroupIds] = useState(new Set()); const [dmName, setDmName] = useState(''); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [showDelete, setShowDelete] = useState(false); const [accordionOpen, setAccordionOpen] = useState(false); const load = useCallback(() => api.getMultiGroupDms().then(({ dms }) => setDms([...(dms||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {}), []); useEffect(() => { load(); }, [load, refreshKey]); const clearSelection = () => { setSelected(null); setDmName(''); setGroupIds(new Set()); setSavedGroupIds(new Set()); setShowDelete(false); }; const selectDm = (dm) => { setShowDelete(false); setAccordionOpen(false); setSelected(dm); setDmName(dm.name); const ids = new Set(dm.memberGroupIds||[]); setGroupIds(ids); setSavedGroupIds(ids); }; const handleSave = async () => { if (!dmName.trim()) return toast('Name required', 'error'); if (!selected && groupIds.size < 2) return toast('Select at least two user groups', 'error'); setSaving(true); try { if (selected) { await api.updateMultiGroupDm(selected.id, { name:dmName.trim(), userGroupIds:[...groupIds] }); toast('Multi-group DM updated', 'success'); const freshDms = await api.getMultiGroupDms(); const fresh = freshDms.dms.find(d => d.id===selected.id); if (fresh) { const ids=new Set(fresh.memberGroupIds||[]); setSavedGroupIds(ids); setGroupIds(ids); setSelected(fresh); } } else { await api.createMultiGroupDm({ name:dmName.trim(), userGroupIds:[...groupIds] }); toast(`"${dmName.trim()}" created`, 'success'); clearSelection(); } load(); onRefresh(); } catch(e) { toast(e.message, 'error'); } finally { setSaving(false); } }; const handleDelete = async () => { setDeleting(true); try { await api.deleteMultiGroupDm(selected.id); toast('Deleted', 'success'); clearSelection(); load(); onRefresh(); } catch(e) { toast(e.message, 'error'); } finally { setDeleting(false); } }; const canDelete = selected && savedGroupIds.size === 0; const isCreating = !selected; return (
{/* Sidebar — desktop only */} {!isMobile && (
Multi-Group DMs
{dms.map(dm => ( ))} {dms.length===0 &&
No multi-group DMs yet
}
)} {/* Mobile accordion */} {isMobile && (
{accordionOpen && (
{dms.map(dm => ( ))} {dms.length===0 &&
No multi-group DMs yet
}
)}
)}
setDmName(e.target.value)} autoComplete="new-password" placeholder="e.g. Coaches + Players" style={{ marginTop:6 }} autoComplete="new-password" onFocus={onIF} onBlur={onIB} />

Select two or more user groups. All their members get access to this conversation.

{groupIds.size} group{groupIds.size!==1?'s':''} selected

{!isCreating && } {!isCreating && ( )}
{showDelete && (

Delete {selected?.name}? Also deletes the associated DM group.

)}
); } // ── U2U Restrictions tab ────────────────────────────────────────────────────── function U2URestrictionsTab({ allUserGroups, isMobile = false, onIF, onIB }) { const toast = useToast(); const [selectedGroup, setSelectedGroup] = useState(null); const [blockedIds, setBlockedIds] = useState(new Set()); const [savedBlockedIds, setSavedBlockedIds] = useState(new Set()); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [search, setSearch] = useState(''); const [accordionOpen, setAccordionOpen] = useState(true); // 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) => { setLoading(true); try { const { blockedGroupIds } = await api.getGroupRestrictions(group.id); const blocked = new Set(blockedGroupIds.map(Number)); setBlockedIds(blocked); setSavedBlockedIds(blocked); } catch (e) { toast(e.message, 'error'); } finally { setLoading(false); } }; const selectGroup = (g) => { setSelectedGroup(g); setSearch(''); setAccordionOpen(false); loadRestrictions(g); }; const clearSelection = () => { setSelectedGroup(null); setBlockedIds(new Set()); setSavedBlockedIds(new Set()); }; const toggleGroup = (id) => { setBlockedIds(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); }; const handleSave = async () => { setSaving(true); try { await api.setGroupRestrictions(selectedGroup.id, [...blockedIds]); setSavedBlockedIds(new Set(blockedIds)); toast('Restrictions saved', 'success'); loadAllCounts(); } catch (e) { toast(e.message, 'error'); } finally { setSaving(false); } }; const isDirty = [...blockedIds].some(id => !savedBlockedIds.has(id)) || [...savedBlockedIds].some(id => !blockedIds.has(id)); // Other groups (excluding the selected group itself) const otherGroups = allUserGroups.filter(g => g.id !== selectedGroup?.id); const filteredGroups = search.trim() ? otherGroups.filter(g => g.name.toLowerCase().includes(search.toLowerCase())) : otherGroups; const u2uGroupButton = (g) => { const hasRestrictions = g.id === selectedGroup?.id ? blockedIds.size > 0 : (restrictionCounts[g.id] || 0) > 0; return ( ); }; return (
{/* Group selector — desktop sidebar */} {!isMobile && (
Select Group
{allUserGroups.map(g => u2uGroupButton(g))} {allUserGroups.length === 0 && (
No user groups yet
)}
)} {/* Mobile accordion — expanded by default, collapses on selection */} {isMobile && (
{accordionOpen && (
{allUserGroups.map(g => { const hasRestrictions = g.id === selectedGroup?.id ? blockedIds.size > 0 : (restrictionCounts[g.id] || 0) > 0; return ( ); })} {allUserGroups.length === 0 &&
No user groups yet
}
)}
)} {/* Restriction editor */}
{!selectedGroup ? (
Select a group
Choose a user group from the left to configure its DM restrictions.
) : (
{/* Header */}

{selectedGroup.name}

Members of {selectedGroup.name} can initiate 1-to-1 direct messages with members of all checked groups. Unchecking a group blocks initiation — existing conversations are preserved. Admins are always exempt. If a user is in multiple groups, the least restrictive rule applies.

{/* Info banner if restrictions exist */} {blockedIds.size > 0 && (
{blockedIds.size} group{blockedIds.size!==1?'s are':' is'} currently blocked from receiving DMs initiated by {selectedGroup.name} members.
)} {/* Search + group list */}
setSearch(e.target.value)} autoComplete="new-password" style={{ marginBottom:8 }} autoComplete="new-password" onFocus={onIF} onBlur={onIB} />
{loading ? (
) : (
{filteredGroups.length === 0 ? (
{search ? 'No groups match your search.' : 'No other groups exist.'}
) : ( filteredGroups.map((g, i) => { const isBlocked = blockedIds.has(g.id); return ( ); }) )}
)} {/* Quick actions */}
{/* Save */}
{isDirty && ( )} {!isDirty && !saving && ( No unsaved changes )}
)}
); } // ── Main page ───────────────────────────────────────────────────────────────── const SIDEBAR_W = 320; export default function GroupManagerPage({ isMobile = false, onProfile, onHelp, onAbout }) { const [tab, setTab] = useState('all'); const [allUsers, setAllUsers] = useState([]); const [allUserGroups, setAllUserGroups] = useState([]); const [refreshKey, setRefreshKey] = useState(0); const [inputFocused, setInputFocused] = useState(false); const onIF = () => setInputFocused(true); const onIB = () => setInputFocused(false); const onRefresh = () => setRefreshKey(k => k+1); useEffect(() => { api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status==='active').sort((a, b) => (a.display_name||a.name).localeCompare(b.display_name||b.name)))).catch(() => {}); api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {}); }, [refreshKey]); // Nav item helper — matches Schedule page style const navItem = (label, key) => ( ); return (
{/* ── Left panel (desktop only) ── */} {!isMobile && (
{/* Title — matches Schedule page */}
Group Manager
{/* Tab navigation */}
View
{navItem('User Groups', 'all')} {navItem('Multi-Group DMs', 'dm')} {navItem('U2U Restrictions', 'u2u')}
)} {/* ── Right panel ── */}
{/* Mobile tab bar — only shown on mobile */} {isMobile && (
Groups
)} {/* Content */}
{tab==='all' && } {tab==='dm' && } {tab==='u2u' && }
{/* Mobile footer — fixed, hidden when any input is focused (keyboard open) */} {isMobile && !inputFocused && (
)}
); }