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 (
+
+ );
+}
+
+// ── 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),