v0.9.63 updated for mobile

This commit is contained in:
2026-03-17 21:17:07 -04:00
parent 85fc75dd19
commit 4602c2e586
12 changed files with 641 additions and 21 deletions

View File

@@ -0,0 +1,131 @@
import { useState, useEffect } from 'react';
import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
import Avatar from './Avatar.jsx';
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 [activeGroup, setActiveGroup] = useState(null);
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'); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const createGroup = async () => {
if(!newName.trim()) return;
setSaving(true);
try {
await api.createUserGroup({ name: newName.trim() });
setNewName(''); setCreating(false); load();
} catch(e) { toast(e.message,'error'); }
finally { setSaving(false); }
};
const deleteGroup = async (g) => {
if(!confirm(`Delete "${g.name}"?`)) return;
try { await api.deleteUserGroup(g.id); load(); } catch(e) { toast(e.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 (
<div style={{ display:'flex',alignItems:'center',justifyContent:'center',height:'100%',color:'var(--text-tertiary)' }}>Loading</div>
);
// Members screen
if(screen==='members' && activeGroup) {
const memberIds = new Set((activeGroup.members||[]).map(m=>m.id||m.user_id));
return (
<div style={{ display:'flex',flexDirection:'column',height:'100%',background:'var(--background)' }}>
<div style={{ display:'flex',alignItems:'center',gap:12,padding:'12px 16px',background:'var(--surface)',borderBottom:'1px solid var(--border)',flexShrink:0 }}>
<button onClick={()=>setScreen('list')} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',display:'flex',alignItems:'center' }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<span style={{ fontWeight:700,fontSize:16,flex:1 }}>{activeGroup.name}</span>
<span style={{ fontSize:13,color:'var(--text-tertiary)' }}>{memberIds.size} member{memberIds.size!==1?'s':''}</span>
</div>
<div style={{ flex:1,overflowY:'auto' }}>
<div style={{ padding:'10px 16px 4px',fontSize:12,fontWeight:600,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.5px' }}>All Users</div>
{allUsers.map(u => {
const isMember = memberIds.has(u.id);
return (
<div key={u.id} style={{ display:'flex',alignItems:'center',gap:12,padding:'12px 16px',borderBottom:'1px solid var(--border)' }}>
<Avatar user={u} size="sm"/>
<div style={{ flex:1,minWidth:0 }}>
<div style={{ fontSize:15,fontWeight:500 }}>{u.display_name||u.name}</div>
<div style={{ fontSize:12,color:'var(--text-tertiary)' }}>{u.role}</div>
</div>
<button onClick={()=>toggleMember(activeGroup.id, u.id, isMember)} style={{ padding:'8px 14px',borderRadius:20,border:`1px solid ${isMember?'var(--error)':'var(--primary)'}`,background:'transparent',color:isMember?'var(--error)':'var(--primary)',fontSize:13,fontWeight:600,cursor:'pointer',flexShrink:0 }}>
{isMember ? 'Remove' : 'Add'}
</button>
</div>
);
})}
</div>
</div>
);
}
// Group list screen
return (
<div style={{ display:'flex',flexDirection:'column',height:'100%',background:'var(--background)' }}>
<div style={{ display:'flex',alignItems:'center',justifyContent:'space-between',padding:'12px 16px',background:'var(--surface)',borderBottom:'1px solid var(--border)',flexShrink:0 }}>
<button onClick={onClose} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',display:'flex' }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<span style={{ fontWeight:700,fontSize:16 }}>Group Manager</span>
<button onClick={()=>setCreating(true)} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--primary)',fontSize:24,lineHeight:1 }}>+</button>
</div>
{creating && (
<div style={{ padding:'12px 16px',background:'var(--surface)',borderBottom:'1px solid var(--border)',display:'flex',gap:10 }}>
<input autoFocus value={newName} onChange={e=>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 }}/>
<button onClick={createGroup} disabled={saving||!newName.trim()} style={{ padding:'8px 16px',background:'var(--primary)',color:'white',border:'none',borderRadius:'var(--radius)',fontSize:14,fontWeight:600,cursor:'pointer',opacity:saving?0.6:1 }}>{saving?'…':'Create'}</button>
<button onClick={()=>{setCreating(false);setNewName('');}} style={{ padding:'8px',background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)' }}></button>
</div>
)}
<div style={{ flex:1,overflowY:'auto' }}>
{groups.length===0 && <div style={{ textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14 }}>No groups yet. Tap + to create one.</div>}
{groups.map(g => (
<div key={g.id} style={{ borderBottom:'1px solid var(--border)' }}>
<div style={{ display:'flex',alignItems:'center',gap:12,padding:'14px 16px',cursor:'pointer' }} onClick={()=>{setActiveGroup(g);setScreen('members');}}>
<div style={{ width:42,height:42,borderRadius:10,background:'var(--primary)',display:'flex',alignItems:'center',justifyContent:'center',color:'white',fontWeight:700,fontSize:14,flexShrink:0 }}>
{g.name.substring(0,2).toUpperCase()}
</div>
<div style={{ flex:1,minWidth:0 }}>
<div style={{ fontSize:15,fontWeight:600 }}>{g.name}</div>
<div style={{ fontSize:12,color:'var(--text-tertiary)' }}>{(g.members||[]).length} member{(g.members||[]).length!==1?'s':''}</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
))}
</div>
</div>
);
}