v0.10.9 update ui settings

This commit is contained in:
2026-03-20 22:28:14 -04:00
parent 8a99fb5ed6
commit d2c157e8d0
7 changed files with 132 additions and 65 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "jama-frontend",
"version": "0.10.8",
"version": "0.10.9",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -353,6 +353,7 @@ export default function Chat() {
/>
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
</div>
);
}
@@ -377,6 +378,7 @@ export default function Chat() {
/>
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
</div>
);
}

View File

@@ -1,5 +1,18 @@
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';
@@ -502,6 +515,7 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp,
const [allUsers, setAllUsers] = useState([]);
const [allUserGroups, setAllUserGroups] = useState([]);
const [refreshKey, setRefreshKey] = useState(0);
const keyboardOpen = useKeyboardOpen();
const onRefresh = () => setRefreshKey(k => k+1);
useEffect(() => {
@@ -509,12 +523,36 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp,
api.getUserGroups().then(({ groups }) => setAllUserGroups(groups)).catch(() => {});
}, [refreshKey]);
// Nav item helper — matches Schedule page style
const navItem = (label, key) => (
<button key={key} onClick={() => setTab(key)}
style={{ display:'block', width:'100%', textAlign:'left', padding:'8px 10px',
borderRadius:'var(--radius)', border:'none',
background: tab===key ? 'var(--primary-light)' : 'transparent',
color: tab===key ? 'var(--primary)' : 'var(--text-primary)',
cursor:'pointer', fontWeight: tab===key ? 600 : 400, fontSize:14, marginBottom:2 }}>
{label}
</button>
);
return (
<div style={{ display:'flex', flex:1, overflow:'hidden', minHeight:0 }}>
{/* ── Left panel (desktop only) ── */}
{!isMobile && (
<div style={{ width:SIDEBAR_W, flexShrink:0, borderRight:'1px solid var(--border)', display:'flex', flexDirection:'column', background:'var(--surface)', overflow:'hidden' }}>
<div style={{ padding:'16px 16px 0' }}>
{/* Title — matches Schedule page */}
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:16 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>
<span style={{ fontSize:16, fontWeight:700, color:'var(--text-primary)' }}>Group Manager</span>
</div>
{/* Tab navigation */}
<div className="section-label" style={{ marginBottom:6 }}>View</div>
{navItem('User Groups', 'all')}
{navItem('Multi-Group DMs', 'dm')}
{navItem('U2U Restrictions', 'u2u')}
</div>
<div style={{ flex:1 }} />
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
</div>
@@ -523,20 +561,15 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp,
{/* ── Right panel ── */}
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minWidth:0, background:'var(--background)' }}>
{/* Header */}
<div style={{ background:'var(--surface)', borderBottom:'1px solid var(--border)', padding:'0 16px', flexShrink:0 }}>
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', height:52, gap:8 }}>
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>
<span style={{ fontWeight:700, fontSize:15 }}>Group Manager</span>
</div>
<div style={{ display:'flex', gap:6, flexShrink:0 }}>
<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')}>{isMobile ? 'Multi-DMs' : 'Multi-Group DMs'}</button>
<button className={`btn btn-sm ${tab==='u2u'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('u2u')}>U2U</button>
</div>
{/* Mobile tab bar — only shown on mobile */}
{isMobile && (
<div style={{ background:'var(--surface)', borderBottom:'1px solid var(--border)', padding:'0 12px', display:'flex', gap:6, height:48, alignItems:'center', flexShrink:0 }}>
<span style={{ fontWeight:700, fontSize:14, marginRight:4, color:'var(--text-primary)' }}>Groups</span>
<button className={`btn btn-sm ${tab==='all'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('all')}>Groups</button>
<button className={`btn btn-sm ${tab==='dm'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('dm')}>Multi-DMs</button>
<button className={`btn btn-sm ${tab==='u2u'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('u2u')}>U2U</button>
</div>
</div>
)}
{/* Content */}
<div style={{ flex:1, display:'flex', overflow: isMobile ? 'auto' : 'hidden', paddingBottom: isMobile ? 70 : 0 }}>
@@ -545,8 +578,8 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp,
{tab==='u2u' && <U2URestrictionsTab allUserGroups={allUserGroups} isMobile={isMobile} />}
</div>
{/* Mobile footer — fixed at bottom, always visible */}
{isMobile && (
{/* Mobile footer — hidden when keyboard is open */}
{isMobile && !keyboardOpen && (
<div style={{ position:'fixed', bottom:0, left:0, right:0, zIndex:20, background:'var(--surface)', borderTop:'1px solid var(--border)' }}>
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
</div>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useToast } from '../contexts/ToastContext.jsx';
import { api } from '../utils/api.js';
import Avatar from '../components/Avatar.jsx';
@@ -24,7 +24,6 @@ function parseCSV(text) {
return { rows, invalid };
}
// ── User row (accordion) ──────────────────────────────────────────────────────
function UserRow({ u, onUpdated }) {
const toast = useToast();
const [open, setOpen] = useState(false);
@@ -160,27 +159,20 @@ function UserRow({ u, onUpdated }) {
);
}
// ── Create user form ──────────────────────────────────────────────────────────
function CreateUserForm({ userPass, onCreated, isMobile }) {
const toast = useToast();
const [form, setForm] = useState({ name:'', email:'', password:'', role:'member' });
const [saving, setSaving] = useState(false);
const set = k => v => setForm(f => ({ ...f, [k]: v }));
const handle = async () => {
if (!form.name.trim() || !form.email.trim()) return toast('Name and email are required', 'error');
if (!isValidEmail(form.email)) return toast('Invalid email address', 'error');
if (!/\S+\s+\S+/.test(form.name.trim())) return toast('Name must be two words (First Last)', 'error');
setSaving(true);
try {
await api.createUser(form);
toast('User created', 'success');
setForm({ name:'', email:'', password:'', role:'member' });
onCreated();
} catch(e) { toast(e.message, 'error'); }
try { await api.createUser(form); toast('User created', 'success'); setForm({ name:'', email:'', password:'', role:'member' }); onCreated(); }
catch(e) { toast(e.message, 'error'); }
finally { setSaving(false); }
};
return (
<div style={{ maxWidth:560 }}>
<div style={{ display:'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap:12, marginBottom:12 }}>
@@ -210,7 +202,6 @@ function CreateUserForm({ userPass, onCreated, isMobile }) {
);
}
// ── Bulk import form ──────────────────────────────────────────────────────────
function BulkImportForm({ userPass, onCreated }) {
const toast = useToast();
const fileRef = useRef(null);
@@ -219,7 +210,6 @@ function BulkImportForm({ userPass, onCreated }) {
const [csvInvalid, setCsvInvalid] = useState([]);
const [bulkResult, setBulkResult] = useState(null);
const [loading, setLoading] = useState(false);
const handleFile = e => {
const file = e.target.files?.[0]; if (!file) return;
setCsvFile(file); setBulkResult(null);
@@ -227,7 +217,6 @@ function BulkImportForm({ userPass, onCreated }) {
reader.onload = ev => { const { rows, invalid } = parseCSV(ev.target.result); setCsvRows(rows); setCsvInvalid(invalid); };
reader.readAsText(file);
};
const handleImport = async () => {
if (!csvRows.length) return;
setLoading(true);
@@ -239,20 +228,16 @@ function BulkImportForm({ userPass, onCreated }) {
} catch(e) { toast(e.message, 'error'); }
finally { setLoading(false); }
};
return (
<div style={{ maxWidth:560 }} className="flex-col gap-4">
<div className="card" style={{ background:'var(--background)', border:'1px dashed var(--border)' }}>
<p className="text-sm font-medium" style={{ marginBottom:6 }}>CSV Format</p>
<code style={{ fontSize:12, color:'var(--text-secondary)', display:'block', background:'var(--surface)', padding:8, borderRadius:4, border:'1px solid var(--border)', whiteSpace:'pre' }}>{"name,email,password,role\nJane Smith,jane@company.com,,member\nBob Jones,bob@company.com,TempPass1,admin"}</code>
<p className="text-xs" style={{ color:'var(--text-tertiary)', marginTop:8 }}>
Name and email required. Blank password defaults to <strong>{userPass}</strong>, blank role defaults to member.
</p>
<p className="text-xs" style={{ color:'var(--text-tertiary)', marginTop:8 }}>Name and email required. Blank password defaults to <strong>{userPass}</strong>, blank role defaults to member.</p>
</div>
<div style={{ display:'flex', alignItems:'center', gap:10, flexWrap:'wrap' }}>
<label className="btn btn-secondary" style={{ cursor:'pointer', margin:0 }}>
Select CSV File
<input ref={fileRef} type="file" accept=".csv,.txt" style={{ display:'none' }} onChange={handleFile} />
Select CSV File<input ref={fileRef} type="file" accept=".csv,.txt" style={{ display:'none' }} onChange={handleFile} />
</label>
{csvFile && <span className="text-sm" style={{ color:'var(--text-secondary)' }}>{csvFile.name}{csvRows.length > 0 && <span style={{ color:'var(--text-tertiary)', marginLeft:6 }}>({csvRows.length} valid)</span>}</span>}
{csvRows.length > 0 && <button className="btn btn-primary" onClick={handleImport} disabled={loading}>{loading ? 'Creating…' : `Create ${csvRows.length} User${csvRows.length!==1?'s':''}`}</button>}
@@ -287,6 +272,19 @@ function BulkImportForm({ userPass, onCreated }) {
);
}
// ── 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;
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function UserManagerPage({ isMobile = false, onProfile, onHelp, onAbout }) {
const [users, setUsers] = useState([]);
@@ -295,21 +293,19 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
const [search, setSearch] = useState('');
const [tab, setTab] = useState('users');
const [userPass, setUserPass] = useState('user@1234');
const [inputFocused, setInputFocused] = useState(false);
const keyboardOpen = useKeyboardOpen();
const load = async () => {
const load = useCallback(async () => {
setLoadError(''); setLoading(true);
try {
const { users } = await api.getUsers();
setUsers(users || []);
} catch(e) { setLoadError(e.message || 'Failed to load users'); }
try { const { users } = await api.getUsers(); setUsers(users || []); }
catch(e) { setLoadError(e.message || 'Failed to load users'); }
finally { setLoading(false); }
};
}, []);
useEffect(() => {
load();
api.getSettings().then(({ settings }) => { if (settings.user_pass) setUserPass(settings.user_pass); }).catch(() => {});
}, []);
}, [load]);
const filtered = users.filter(u =>
!search || u.name?.toLowerCase().includes(search.toLowerCase()) ||
@@ -317,12 +313,36 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
u.email?.toLowerCase().includes(search.toLowerCase())
);
// ── Nav item helper (matches Schedule page style) ─────────────────────────
const navItem = (label, key) => (
<button key={key} onClick={() => setTab(key)}
style={{ display:'block', width:'100%', textAlign:'left', padding:'8px 10px',
borderRadius:'var(--radius)', border:'none',
background: tab===key ? 'var(--primary-light)' : 'transparent',
color: tab===key ? 'var(--primary)' : 'var(--text-primary)',
cursor:'pointer', fontWeight: tab===key ? 600 : 400, fontSize:14, marginBottom:2 }}>
{label}
</button>
);
return (
<div style={{ display:'flex', flex:1, overflow:'hidden', minHeight:0 }}>
{/* ── Left panel (desktop only) — blank, reserved for future use ── */}
{/* ── Left panel ── */}
{!isMobile && (
<div style={{ width:SIDEBAR_W, flexShrink:0, borderRight:'1px solid var(--border)', display:'flex', flexDirection:'column', background:'var(--surface)', overflow:'hidden' }}>
<div style={{ padding:'16px 16px 0' }}>
{/* Title — matches Schedule page */}
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:16 }}>
<svg width="16" height="16" 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={{ fontSize:16, fontWeight:700, color:'var(--text-primary)' }}>User Manager</span>
</div>
{/* Tab navigation */}
<div className="section-label" style={{ marginBottom:6 }}>View</div>
{navItem(`All Users${!loading ? ` (${users.length})` : ''}`, 'users')}
{navItem('+ Create User', 'create')}
{navItem('Bulk Import CSV', 'bulk')}
</div>
<div style={{ flex:1 }} />
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
</div>
@@ -331,28 +351,20 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
{/* ── Right panel ── */}
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minWidth:0, background:'var(--background)' }}>
{/* Header */}
<div style={{ background:'var(--surface)', borderBottom:'1px solid var(--border)', padding:'0 16px', flexShrink:0 }}>
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', height:52, gap:8 }}>
<div style={{ display:'flex', alignItems:'center', gap:8, minWidth:0 }}>
<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>
{!loading && <span style={{ fontSize:12, color:'var(--text-tertiary)', flexShrink:0 }}>{users.length} user{users.length!==1?'s':''}</span>}
</div>
<div style={{ display:'flex', gap:6, flexShrink:0 }}>
<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>
{!isMobile && <button className={`btn btn-sm ${tab==='bulk'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('bulk')}>Bulk</button>}
</div>
{/* Mobile tab bar — only on mobile since desktop uses left panel */}
{isMobile && (
<div style={{ background:'var(--surface)', borderBottom:'1px solid var(--border)', padding:'0 12px', display:'flex', gap:6, height:48, alignItems:'center', flexShrink:0 }}>
<span style={{ fontWeight:700, fontSize:14, marginRight:4, color:'var(--text-primary)' }}>Users</span>
<button className={`btn btn-sm ${tab==='users'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('users')}>All</button>
<button className={`btn btn-sm ${tab==='create'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('create')}>+ Create</button>
</div>
</div>
)}
{/* Content */}
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 86 : 16 }}>
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 80 : 16 }}>
{tab === 'users' && (
<>
<input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)}
onFocus={() => setInputFocused(true)} onBlur={() => setInputFocused(false)}
autoComplete="new-password" autoCorrect="off" spellCheck={false}
style={{ marginBottom:16, width:'100%', maxWidth: isMobile ? '100%' : 400 }} />
<div style={{ background:'var(--surface)', borderRadius:'var(--radius)', boxShadow:'var(--shadow-sm)', overflow:'hidden' }}>
@@ -377,8 +389,8 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
{tab === 'bulk' && <BulkImportForm userPass={userPass} onCreated={load} />}
</div>
{/* Mobile footer — fixed at bottom, hidden when keyboard open */}
{isMobile && !inputFocused && (
{/* Mobile footer — hidden when keyboard is open */}
{isMobile && !keyboardOpen && (
<div style={{ position:'fixed', bottom:0, left:0, right:0, zIndex:20, background:'var(--surface)', borderTop:'1px solid var(--border)' }}>
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
</div>