From cb12804ca2e93078cd178ac785cea9687f36470d Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Tue, 17 Mar 2026 22:05:43 -0400 Subject: [PATCH] V0.9.65 ui changes --- .env.example | 2 +- backend/package.json | 2 +- build.sh | 2 +- frontend/package.json | 2 +- frontend/src/components/MobileEventForm.jsx | 20 +- .../src/components/MobileGroupManager.jsx | 296 +++++++++++++----- frontend/src/components/NavDrawer.jsx | 6 - frontend/src/components/UserManagerModal.jsx | 5 +- frontend/src/pages/Chat.jsx | 1 + frontend/src/utils/api.js | 5 + 10 files changed, 232 insertions(+), 109 deletions(-) diff --git a/.env.example b/.env.example index 09b277c..2746189 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ PROJECT_NAME=jama # Image version to run (set by build.sh, or use 'latest') -JAMA_VERSION=0.9.64 +JAMA_VERSION=0.9.65 # App port — the host port Docker maps to the container PORT=3000 diff --git a/backend/package.json b/backend/package.json index f7a7ccc..edafddf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.9.64", + "version": "0.9.65", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/build.sh b/build.sh index 99720e0..ad8d95a 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.9.64}" +VERSION="${1:-0.9.65}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index 9c40787..8a3c719 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.9.64", + "version": "0.9.65", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/MobileEventForm.jsx b/frontend/src/components/MobileEventForm.jsx index b9deec5..59931bb 100644 --- a/frontend/src/components/MobileEventForm.jsx +++ b/frontend/src/components/MobileEventForm.jsx @@ -270,9 +270,13 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte {/* Start date/time */} -
setShowStartDate(true)} style={{ display:'flex',alignItems:'center',padding:'12px 20px 6px 56px',cursor:'pointer' }}> - {fmtDateDisplay(sd)} - {!allDay && {fmtTimeDisplay(st)}} +
+ setShowStartDate(true)} style={{ flex:1,fontSize:15,cursor:'pointer' }}>{fmtDateDisplay(sd)} + {!allDay && ( + + )}
{/* End date/time */} @@ -285,15 +289,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte )}
- {/* Start time picker (show only if !allDay and user didn't tap date) */} - {!allDay && ( -
- Start - -
- )} + {/* Recurrence */} } onPress={()=>setShowRecurrence(true)}> diff --git a/frontend/src/components/MobileGroupManager.jsx b/frontend/src/components/MobileGroupManager.jsx index 2f3b929..ba4f030 100644 --- a/frontend/src/components/MobileGroupManager.jsx +++ b/frontend/src/components/MobileGroupManager.jsx @@ -3,29 +3,190 @@ import { api } from '../utils/api.js'; import { useToast } from '../contexts/ToastContext.jsx'; import Avatar from './Avatar.jsx'; +// ── Shared back header ──────────────────────────────────────────────────────── +function Header({ title, onBack, right }) { + return ( +
+ {onBack && ( + + )} + {title} + {right} +
+ ); +} + +// ── Members screen ──────────────────────────────────────────────────────────── +function MembersScreen({ group, allUsers, onBack }) { + const toast = useToast(); + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + + const loadMembers = async () => { + try { + const r = await api.getUserGroup(group.id); + setMembers(r.members || []); + } catch(e) { toast(e.message, 'error'); } + finally { setLoading(false); } + }; + useEffect(() => { loadMembers(); }, [group.id]); + + const memberIds = new Set(members.map(m => m.id)); + + const toggle = async (user) => { + const nowMember = memberIds.has(user.id); + // Optimistic update + if(nowMember) setMembers(prev => prev.filter(m => m.id !== user.id)); + else setMembers(prev => [...prev, user]); + try { + const newIds = nowMember + ? members.filter(m => m.id !== user.id).map(m => m.id) + : [...members.map(m => m.id), user.id]; + await api.updateUserGroupMembers(group.id, newIds); + } catch(e) { + toast(e.message, 'error'); + loadMembers(); // revert on error + } + }; + + return ( +
+
{members.length} member{members.length!==1?'s':''}} + /> + {loading ? ( +
Loading…
+ ) : ( +
+
All Users
+ {allUsers.map(u => { + const isMember = memberIds.has(u.id); + return ( +
+ +
+
{u.display_name||u.name}
+
{u.role}
+
+ +
+ ); + })} +
+ )} +
+ ); +} + +// ── Multi-Group DM screen ───────────────────────────────────────────────────── +function MultiGroupDmsScreen({ userGroups, onBack }) { + const toast = useToast(); + const [dms, setDms] = useState([]); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(''); + const [selectedGroups, setSelectedGroups] = useState(new Set()); + const [saving, setSaving] = useState(false); + + const load = async () => { + try { + const r = await api.getMultiGroupDms(); + setDms(r.dms || []); + } catch(e) { toast(e.message,'error'); } + finally { setLoading(false); } + }; + useEffect(() => { load(); }, []); + + const create = async () => { + if(!newName.trim() || selectedGroups.size < 2) return toast('Name and at least 2 groups required','error'); + setSaving(true); + try { + await api.createMultiGroupDm({ name: newName.trim(), userGroupIds: [...selectedGroups] }); + setNewName(''); setSelectedGroups(new Set()); setCreating(false); load(); + } catch(e) { toast(e.message,'error'); } + finally { setSaving(false); } + }; + + const deleteDm = async (dm) => { + if(!confirm(`Delete "${dm.name}"?`)) return; + try { await api.deleteMultiGroupDm(dm.id); load(); } catch(e) { toast(e.message,'error'); } + }; + + const toggleGrp = id => setSelectedGroups(prev => { const n=new Set(prev); n.has(id)?n.delete(id):n.add(id); return n; }); + + return ( +
+
setCreating(v=>!v)} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--primary)',fontSize:24,lineHeight:1,padding:0 }}>+} + /> + {creating && ( +
+ setNewName(e.target.value)} placeholder="DM name…" style={{ width:'100%',padding:'9px 12px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:15,marginBottom:10,boxSizing:'border-box' }}/> +
Select groups (min 2):
+ {userGroups.map(g=>( + + ))} +
+ + +
+
+ )} +
+ {loading &&
Loading…
} + {!loading && dms.length===0 &&
No multi-group DMs yet. Tap + to create one.
} + {dms.map(dm=>( +
+
MG
+
+
{dm.name}
+
{dm.group_count} group{dm.group_count!==1?'s':''}
+
+ +
+ ))} +
+
+ ); +} + +// ── Group list screen ───────────────────────────────────────────────────────── export default function MobileGroupManager({ onClose }) { const toast = useToast(); const [groups, setGroups] = useState([]); const [allUsers, setAllUsers] = useState([]); - const [expanded, setExpanded] = useState(null); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(''); const [saving, setSaving] = useState(false); - const [screen, setScreen] = useState('list'); // list | members + const [screen, setScreen] = useState('list'); // list | members | mgdms const [activeGroup, setActiveGroup] = useState(null); + const [tab, setTab] = useState('groups'); // groups | mgdms const load = async () => { try { const [ug, us] = await Promise.all([api.getUserGroups(), api.getUsers()]); setGroups(ug.groups || []); setAllUsers(us.users || []); - } catch(e) { toast(e.message, 'error'); } + } catch(e) { toast(e.message,'error'); } finally { setLoading(false); } }; - useEffect(() => { load(); }, []); + if(screen === 'members' && activeGroup) return {setScreen('list');load();}}/>; + if(screen === 'mgdms') return setScreen('list')}/>; + const createGroup = async () => { if(!newName.trim()) return; setSaving(true); @@ -36,96 +197,59 @@ export default function MobileGroupManager({ onClose }) { finally { setSaving(false); } }; - const deleteGroup = async (g) => { + const deleteGroup = async (g, e) => { + e.stopPropagation(); if(!confirm(`Delete "${g.name}"?`)) return; - try { await api.deleteUserGroup(g.id); load(); } catch(e) { toast(e.message,'error'); } + try { await api.deleteUserGroup(g.id); load(); } catch(e2) { toast(e2.message,'error'); } }; - const toggleMember = async (groupId, userId, isMember) => { - try { - if(isMember) await api.removeFromUserGroup(groupId, userId); - else await api.addToUserGroup(groupId, userId); - // Reload group members - const ug = await api.getUserGroups(); - setGroups(ug.groups || []); - if(activeGroup) setActiveGroup((ug.groups||[]).find(g=>g.id===activeGroup.id)||null); - } catch(e) { toast(e.message,'error'); } - }; - - if(loading) return ( -
Loading…
- ); - - // Members screen - if(screen==='members' && activeGroup) { - const memberIds = new Set((activeGroup.members||[]).map(m=>m.id||m.user_id)); - return ( -
-
- - {activeGroup.name} - {memberIds.size} member{memberIds.size!==1?'s':''} -
-
-
All Users
- {allUsers.map(u => { - const isMember = memberIds.has(u.id); - return ( -
- -
-
{u.display_name||u.name}
-
{u.role}
-
- -
- ); - })} -
-
- ); - } - - // Group list screen return (
-
- - Group Manager - -
+
setCreating(v=>!v)} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--primary)',fontSize:24,lineHeight:1,padding:0 }}>+} + /> - {creating && ( -
- setNewName(e.target.value)} onKeyDown={e=>e.key==='Enter'&&createGroup()} placeholder="Group name…" style={{ flex:1,padding:'8px 12px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:15 }}/> - - -
- )} - -
- {groups.length===0 &&
No groups yet. Tap + to create one.
} - {groups.map(g => ( -
-
{setActiveGroup(g);setScreen('members');}}> -
- {g.name.substring(0,2).toUpperCase()} -
-
-
{g.name}
-
{(g.members||[]).length} member{(g.members||[]).length!==1?'s':''}
-
- -
-
+ {/* Tab bar */} +
+ {[['groups','All Groups'],['mgdms','Multi-Group DMs']].map(([key,label])=>( + ))}
+ + {tab === 'mgdms' && setTab('groups')}/>} + + {tab === 'groups' && ( + <> + {creating && ( +
+ setNewName(e.target.value)} onKeyDown={e=>e.key==='Enter'&&createGroup()} placeholder="Group name…" style={{ flex:1,padding:'8px 12px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:15 }}/> + + +
+ )} +
+ {loading &&
Loading…
} + {!loading && groups.length===0 &&
No groups yet. Tap + to create one.
} + {groups.map(g=>( +
{setActiveGroup(g);setScreen('members');}}> +
+ {g.name.substring(0,2).toUpperCase()} +
+
+
{g.name}
+
{g.member_count||0} member{g.member_count!==1?'s':''}
+
+
+ + +
+
+ ))} +
+ + )}
); } diff --git a/frontend/src/components/NavDrawer.jsx b/frontend/src/components/NavDrawer.jsx index cec09cd..4e04bee 100644 --- a/frontend/src/components/NavDrawer.jsx +++ b/frontend/src/components/NavDrawer.jsx @@ -79,12 +79,6 @@ export default function NavDrawer({ open, onClose, onMessages, onSchedule, onSch
Tools
{item(NAV_ICON.users, 'User Manager', onUsers)} {features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager)} - {features.scheduleManager && item( - NAV_ICON.schedules, - 'Schedule Manager', - isMobile ? () => {} : onScheduleManager, - { disabled: isMobile, badge: isMobile ? 'Desktop only' : undefined } - )} )}
diff --git a/frontend/src/components/UserManagerModal.jsx b/frontend/src/components/UserManagerModal.jsx index 2eb3e23..5a853e4 100644 --- a/frontend/src/components/UserManagerModal.jsx +++ b/frontend/src/components/UserManagerModal.jsx @@ -201,11 +201,14 @@ function UserRow({ u, onUpdated }) { } export default function UserManagerModal({ onClose }) { + const isMobile = window.innerWidth < 768; const toast = useToast(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [tab, setTab] = useState('users'); + // Reset bulk tab if somehow active on mobile + useEffect(() => { if(isMobile && tab === 'bulk') setTab('users'); }, [isMobile]); const [creating, setCreating] = useState(false); const [form, setForm] = useState({ name: '', email: '', password: '', role: 'member' }); @@ -293,7 +296,7 @@ export default function UserManagerModal({ onClose }) {
- + {!isMobile && }
{/* Users list — accordion */} diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index c16acdb..ff709d2 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -361,6 +361,7 @@ export default function Chat() { isMobile={isMobile} /> {modal === 'profile' && setModal(null)} />} + {modal === 'users' && setModal(null)} />} {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'branding' && setModal(null)} />} {modal === 'groupmanager' && setModal(null)} />} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 0ae796d..eafd92e 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -134,6 +134,11 @@ export const api = { createUserGroup: (body) => req('POST', '/usergroups', body), updateUserGroup: (id, body) => req('PATCH', `/usergroups/${id}`, body), deleteUserGroup: (id) => req('DELETE', `/usergroups/${id}`), + getUserGroup: (id) => req('GET', `/usergroups/${id}`), + updateUserGroupMembers: (id, memberIds) => req('PATCH', `/usergroups/${id}`, { memberIds }), + getMultiGroupDms: () => req('GET', '/usergroups/multigroup'), + createMultiGroupDm: (body) => req('POST', '/usergroups/multigroup', body), + deleteMultiGroupDm: (id) => req('DELETE', `/usergroups/multigroup/${id}`), // Multi-group DMs getMultiGroupDms: () => req('GET', '/usergroups/multigroup'), createMultiGroupDm: (body) => req('POST', '/usergroups/multigroup', body),