v0.9.26 added a admin tools
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-frontend",
|
||||
"version": "0.9.25",
|
||||
"version": "0.9.26",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
export default function GlobalBar({ isMobile, showSidebar }) {
|
||||
export default function GlobalBar({ isMobile, showSidebar, onBurger }) {
|
||||
const { connected } = useSocket();
|
||||
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' });
|
||||
const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark');
|
||||
@@ -11,7 +11,6 @@ export default function GlobalBar({ isMobile, showSidebar }) {
|
||||
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||
const handler = () => api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||
window.addEventListener('jama:settings-changed', handler);
|
||||
// Re-render when theme changes so title colour switches correctly
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
|
||||
});
|
||||
@@ -26,20 +25,34 @@ export default function GlobalBar({ isMobile, showSidebar }) {
|
||||
const logoUrl = settings.logo_url;
|
||||
const titleColor = (isDark ? settings.color_title_dark : settings.color_title) || null;
|
||||
|
||||
// On mobile: show bar only when sidebar is visible (chat list view)
|
||||
// On desktop: always show
|
||||
if (isMobile && !showSidebar) return null;
|
||||
|
||||
return (
|
||||
<div className="global-bar">
|
||||
{/* Burger menu button */}
|
||||
<button
|
||||
onClick={onBurger}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text-primary)', padding: '4px 6px',
|
||||
display: 'flex', alignItems: 'center', flexShrink: 0, borderRadius: 8,
|
||||
marginRight: 4,
|
||||
}}
|
||||
title="Menu"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="global-bar-brand">
|
||||
<img
|
||||
src={logoUrl || '/icons/jama.png'}
|
||||
alt={appName}
|
||||
className="global-bar-logo"
|
||||
/>
|
||||
<img src={logoUrl || '/icons/jama.png'} alt={appName} className="global-bar-logo" />
|
||||
<span className="global-bar-title" style={titleColor ? { color: titleColor } : {}}>{appName}</span>
|
||||
</div>
|
||||
|
||||
{!connected && (
|
||||
<span className="global-bar-offline" title="Offline">
|
||||
<span className="offline-dot" />
|
||||
|
||||
306
frontend/src/components/GroupManagerModal.jsx
Normal file
306
frontend/src/components/GroupManagerModal.jsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import Avatar from './Avatar.jsx';
|
||||
|
||||
// ── Shared user list checkbox ─────────────────────────────────────────────────
|
||||
function UserCheckList({ allUsers, selectedIds, onChange }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const filtered = allUsers.filter(u =>
|
||||
(u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)} style={{ marginBottom: 8 }} />
|
||||
<div style={{ maxHeight: 220, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
||||
{filtered.map(u => (
|
||||
<label key={u.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '9px 14px', borderBottom: '1px solid var(--border)', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={selectedIds.has(u.id)} onChange={() => {
|
||||
const next = new Set(selectedIds);
|
||||
next.has(u.id) ? next.delete(u.id) : next.add(u.id);
|
||||
onChange(next);
|
||||
}} style={{ accentColor: 'var(--primary)', width: 15, height: 15 }} />
|
||||
<Avatar user={u} size="sm" />
|
||||
<span className="flex-1 text-sm">{u.display_name || u.name}</span>
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{u.role}</span>
|
||||
</label>
|
||||
))}
|
||||
{filtered.length === 0 && <div style={{ padding: '16px', textAlign: 'center', color: 'var(--text-tertiary)', fontSize: 13 }}>No users found</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── All Groups tab ────────────────────────────────────────────────────────────
|
||||
function AllGroupsTab({ allUsers, onRefresh }) {
|
||||
const toast = useToast();
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [selected, setSelected] = useState(null); // selected user group
|
||||
const [members, setMembers] = useState(new Set());
|
||||
const [editName, setEditName] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
|
||||
const load = () => api.getUserGroups().then(({ groups }) => setGroups(groups)).catch(() => {});
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const selectGroup = async (g) => {
|
||||
const { group, members: mems } = await api.getUserGroup(g.id);
|
||||
setSelected(group);
|
||||
setEditName(group.name);
|
||||
setMembers(new Set(mems.map(m => m.id)));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editName.trim()) return toast('Name required', 'error');
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateUserGroup(selected.id, { name: editName.trim(), memberIds: [...members] });
|
||||
toast('Group updated', 'success');
|
||||
load();
|
||||
setSelected(prev => ({ ...prev, name: editName.trim() }));
|
||||
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');
|
||||
setSelected(null); setShowDelete(false);
|
||||
load(); onRefresh();
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
finally { setDeleting(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 24, height: '100%' }}>
|
||||
{/* Group list */}
|
||||
<div style={{ width: 240, flexShrink: 0, borderRight: '1px solid var(--border)', paddingRight: 20 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-secondary)', marginBottom: 10, textTransform: 'uppercase', letterSpacing: '0.5px' }}>User Groups</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{groups.map(g => (
|
||||
<button key={g.id} onClick={() => selectGroup(g)} style={{
|
||||
textAlign: 'left', padding: '9px 12px', borderRadius: 8, border: 'none',
|
||||
background: selected?.id === g.id ? 'var(--primary-light)' : 'transparent',
|
||||
color: selected?.id === g.id ? 'var(--primary)' : 'var(--text-primary)',
|
||||
cursor: 'pointer', fontWeight: selected?.id === g.id ? 600 : 400, fontSize: 14,
|
||||
}}>
|
||||
<div>{g.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{g.member_count} member{g.member_count !== 1 ? 's' : ''}</div>
|
||||
</button>
|
||||
))}
|
||||
{groups.length === 0 && <div style={{ fontSize: 13, color: 'var(--text-tertiary)', padding: '12px 0' }}>No groups yet</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit panel */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{!selected ? (
|
||||
<div style={{ color: 'var(--text-tertiary)', fontSize: 14, paddingTop: 40, textAlign: 'center' }}>Select a group to edit</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div>
|
||||
<label className="settings-section-label">Group Name</label>
|
||||
<input className="input" value={editName} onChange={e => setEditName(e.target.value)} style={{ marginTop: 6 }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="settings-section-label">Members</label>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<UserCheckList allUsers={allUsers} selectedIds={members} onChange={setMembers} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setSelected(null); setShowDelete(false); }}>Cancel</button>
|
||||
<button className="btn btn-sm" style={{ marginLeft: 'auto', background: 'var(--error)', color: 'white' }} onClick={() => setShowDelete(true)}>Delete Group</button>
|
||||
</div>
|
||||
{showDelete && (
|
||||
<div style={{ background: '#fce8e6', border: '1px solid #f5c6c2', borderRadius: 'var(--radius)', padding: '14px 16px' }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}>Delete <strong>{selected.name}</strong>? This also deletes the associated direct message group. This cannot be undone.</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleDelete} disabled={deleting}>{deleting ? 'Deleting…' : 'Yes, Delete'}</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowDelete(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Create Group tab ──────────────────────────────────────────────────────────
|
||||
function CreateGroupTab({ allUsers, onRefresh }) {
|
||||
const toast = useToast();
|
||||
const [name, setName] = useState('');
|
||||
const [members, setMembers] = useState(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return toast('Group name required', 'error');
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.createUserGroup({ name: name.trim(), memberIds: [...members] });
|
||||
toast(`Group "${name.trim()}" created`, 'success');
|
||||
setName(''); setMembers(new Set());
|
||||
onRefresh();
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div>
|
||||
<label className="settings-section-label">Group Name</label>
|
||||
<input className="input" value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Coaches" style={{ marginTop: 6 }} onKeyDown={e => e.key === 'Enter' && handleCreate()} />
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>A matching Direct Message group will be created automatically with the same name.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="settings-section-label">Members</label>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<UserCheckList allUsers={allUsers} selectedIds={members} onChange={setMembers} />
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>{members.size} user{members.size !== 1 ? 's' : ''} selected</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-primary" onClick={handleCreate} disabled={saving}>{saving ? 'Creating…' : 'Create Group'}</button>
|
||||
<button className="btn btn-secondary" onClick={() => { setName(''); setMembers(new Set()); }}>Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direct Messages tab ───────────────────────────────────────────────────────
|
||||
function DirectMessagesTab({ allUsers }) {
|
||||
const toast = useToast();
|
||||
const [groups, setGroups] = useState([]); // user groups
|
||||
const [dmGroups, setDmGroups] = useState([]); // managed DM groups
|
||||
const [selectedDm, setSelectedDm] = useState(null);
|
||||
const [dmName, setDmName] = useState('');
|
||||
const [dmGroupMembers, setDmGroupMembers] = useState(new Set()); // selected user group IDs
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const [ugRes] = await Promise.all([api.getUserGroups()]);
|
||||
setGroups(ugRes.groups || []);
|
||||
// Also need managed DM groups — these are the paired DMs (from groups table, is_managed=1, is_direct=0)
|
||||
// We get them via the user groups list (each has dm_group_id)
|
||||
setDmGroups(ugRes.groups || []);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const selectDm = (g) => {
|
||||
setSelectedDm(g);
|
||||
setDmName(g.name);
|
||||
// Pre-select this group (since each managed DM maps 1:1 to a user group)
|
||||
setDmGroupMembers(new Set([g.id]));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!dmName.trim()) return toast('Name required', 'error');
|
||||
if (!selectedDm) return;
|
||||
saving || setSaving(true);
|
||||
try {
|
||||
await api.updateUserGroup(selectedDm.id, { name: dmName.trim() });
|
||||
toast('Direct message group renamed', 'success');
|
||||
load();
|
||||
setSelectedDm(null); setDmName(''); setDmGroupMembers(new Set());
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleCancel = () => { setSelectedDm(null); setDmName(''); setDmGroupMembers(new Set()); };
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 24, height: '100%' }}>
|
||||
<div style={{ width: 240, flexShrink: 0, borderRight: '1px solid var(--border)', paddingRight: 20 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-secondary)', marginBottom: 10, textTransform: 'uppercase', letterSpacing: '0.5px' }}>Managed DM Groups</div>
|
||||
{dmGroups.map(g => (
|
||||
<button key={g.id} onClick={() => selectDm(g)} style={{
|
||||
display: 'block', width: '100%', textAlign: 'left', padding: '9px 12px', borderRadius: 8, border: 'none',
|
||||
background: selectedDm?.id === g.id ? 'var(--primary-light)' : 'transparent',
|
||||
color: selectedDm?.id === g.id ? 'var(--primary)' : 'var(--text-primary)',
|
||||
cursor: 'pointer', fontWeight: selectedDm?.id === g.id ? 600 : 400, fontSize: 14, marginBottom: 2,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, background: 'var(--primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontSize: 10, fontWeight: 700, flexShrink: 0 }}>MG</div>
|
||||
{g.name}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{dmGroups.length === 0 && <div style={{ fontSize: 13, color: 'var(--text-tertiary)', padding: '12px 0' }}>No managed DM groups</div>}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
{!selectedDm ? (
|
||||
<div style={{ color: 'var(--text-tertiary)', fontSize: 14, paddingTop: 40, textAlign: 'center' }}>Select a group to edit its name</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 460 }}>
|
||||
<div>
|
||||
<label className="settings-section-label">Display Name</label>
|
||||
<input className="input" value={dmName} onChange={e => setDmName(e.target.value)} style={{ marginTop: 6 }} />
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>Renaming this also renames the paired user group.</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleCancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main modal ────────────────────────────────────────────────────────────────
|
||||
export default function GroupManagerModal({ onClose }) {
|
||||
const [tab, setTab] = useState('all');
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const onRefresh = () => setRefreshKey(k => k + 1);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch all active users (ignore allow_dm — admin can add anyone)
|
||||
api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status === 'active'))).catch(() => {});
|
||||
}, [refreshKey]);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'all', label: 'All Groups' },
|
||||
{ id: 'create', label: 'Create Group' },
|
||||
{ id: 'dm', label: 'Direct Messages' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 1024, width: '96vw', height: '85vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 16, flexShrink: 0 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Group Manager</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab buttons */}
|
||||
<div className="flex gap-2" style={{ marginBottom: 20, flexShrink: 0 }}>
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} className={`btn btn-sm ${tab === t.id ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t.id)}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{tab === 'all' && <AllGroupsTab allUsers={allUsers} onRefresh={onRefresh} />}
|
||||
{tab === 'create' && <CreateGroupTab allUsers={allUsers} onRefresh={onRefresh} />}
|
||||
{tab === 'dm' && <DirectMessagesTab allUsers={allUsers} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/NavDrawer.css
Normal file
79
frontend/src/components/NavDrawer.css
Normal file
@@ -0,0 +1,79 @@
|
||||
.nav-drawer-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
z-index: 1200;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
.nav-drawer-backdrop.open {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 260px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
z-index: 1300;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s cubic-bezier(0.4,0,0.2,1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 12px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 4px 0 24px rgba(0,0,0,0.12);
|
||||
}
|
||||
.nav-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.nav-drawer-section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
padding: 4px 12px 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.nav-drawer-section-label:first-child { margin-top: 0; }
|
||||
.nav-drawer-section-label.admin {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-drawer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.nav-drawer-item:hover:not(.disabled) { background: var(--background); }
|
||||
.nav-drawer-item.disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
.nav-drawer-badge {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: var(--surface-variant);
|
||||
color: var(--text-tertiary);
|
||||
border-radius: 20px;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
71
frontend/src/components/NavDrawer.jsx
Normal file
71
frontend/src/components/NavDrawer.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import './NavDrawer.css';
|
||||
|
||||
const NAV_ICON = {
|
||||
messages: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>,
|
||||
schedules: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>,
|
||||
users: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>,
|
||||
groups: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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"/><line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/></svg>,
|
||||
branding: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 2a10 10 0 1 0 10 10"/></svg>,
|
||||
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>,
|
||||
};
|
||||
|
||||
export default function NavDrawer({ open, onClose, onMessages, onGroupManager, onBranding, onSettings, onUsers, features = {} }) {
|
||||
const { user } = useAuth();
|
||||
const drawerRef = useRef(null);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => {
|
||||
if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
const item = (icon, label, onClick, disabled = false) => (
|
||||
<button
|
||||
className={`nav-drawer-item${disabled ? ' disabled' : ''}`}
|
||||
onClick={disabled ? undefined : () => { onClose(); onClick(); }}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
{disabled && <span className="nav-drawer-badge">Coming soon</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className={`nav-drawer-backdrop${open ? ' open' : ''}`} onClick={onClose} />
|
||||
{/* Drawer */}
|
||||
<div ref={drawerRef} className={`nav-drawer${open ? ' open' : ''}`}>
|
||||
<div className="nav-drawer-section-label">Menu</div>
|
||||
{item(NAV_ICON.messages, 'Messages', onMessages)}
|
||||
{item(NAV_ICON.schedules, 'Schedules', () => {}, true)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="nav-drawer-section-label admin">Admin</div>
|
||||
{item(NAV_ICON.users, 'User Manager', onUsers)}
|
||||
{features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager)}
|
||||
{features.branding && item(NAV_ICON.branding, 'Branding', onBranding)}
|
||||
{item(NAV_ICON.settings, 'Settings', onSettings)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,27 @@ import { useState, useEffect } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
|
||||
export default function SettingsModal({ onClose }) {
|
||||
export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
||||
const toast = useToast();
|
||||
const [vapidPublic, setVapidPublic] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [showRegenWarning, setShowRegenWarning] = useState(false);
|
||||
|
||||
// Registration
|
||||
const [regCode, setRegCode] = useState('');
|
||||
const [activeCode, setActiveCode] = useState('');
|
||||
const [features, setFeatures] = useState({ branding: false, groupManager: false });
|
||||
const [regLoading, setRegLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setVapidPublic(settings.vapid_public || '');
|
||||
setActiveCode(settings.registration_code || '');
|
||||
setFeatures({
|
||||
branding: settings.feature_branding === 'true',
|
||||
groupManager: settings.feature_group_manager === 'true',
|
||||
});
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
@@ -31,17 +42,35 @@ export default function SettingsModal({ onClose }) {
|
||||
};
|
||||
|
||||
const handleGenerateClick = () => {
|
||||
if (vapidPublic) {
|
||||
setShowRegenWarning(true);
|
||||
} else {
|
||||
doGenerate();
|
||||
if (vapidPublic) setShowRegenWarning(true);
|
||||
else doGenerate();
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
setRegLoading(true);
|
||||
try {
|
||||
const { features: f } = await api.registerCode(regCode.trim());
|
||||
setFeatures(f);
|
||||
setActiveCode(regCode.trim());
|
||||
setRegCode('');
|
||||
toast(regCode.trim() ? 'Registration code applied.' : 'Registration cleared.', 'success');
|
||||
window.dispatchEvent(new Event('jama:settings-changed'));
|
||||
onFeaturesChanged && onFeaturesChanged(f);
|
||||
} catch (e) {
|
||||
toast(e.message || 'Invalid code', 'error');
|
||||
} finally {
|
||||
setRegLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const featureList = [
|
||||
features.branding && 'Branding',
|
||||
features.groupManager && 'Group Manager',
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 480 }}>
|
||||
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Settings</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
@@ -49,84 +78,78 @@ export default function SettingsModal({ onClose }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="settings-section-label">Web Push Notifications (VAPID)</div>
|
||||
{/* Registration Code */}
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<div className="settings-section-label">Feature Registration</div>
|
||||
{activeCode && featureList.length > 0 && (
|
||||
<div style={{ marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--success)' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
Active — unlocks: {featureList.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="Enter registration code"
|
||||
value={regCode}
|
||||
onChange={e => setRegCode(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleRegister()}
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleRegister} disabled={regLoading}>
|
||||
{regLoading ? '…' : 'Apply'}
|
||||
</button>
|
||||
</div>
|
||||
{activeCode && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setRegCode(''); api.registerCode('').then(() => { setFeatures({ branding: false, groupManager: false }); setActiveCode(''); window.dispatchEvent(new Event('jama:settings-changed')); onFeaturesChanged && onFeaturesChanged({ branding: false, groupManager: false }); }); }}>
|
||||
Clear Registration
|
||||
</button>
|
||||
)}
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 8 }}>
|
||||
A registration code unlocks Branding and Group Manager features. Contact your jama provider for a code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
|
||||
<div className="settings-section-label">Web Push Notifications (VAPID)</div>
|
||||
{loading ? (
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)" }}>Loading…</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
{vapidPublic ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{
|
||||
background: "var(--surface-variant)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius)",
|
||||
padding: "10px 12px",
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.5px" }}>
|
||||
Public Key
|
||||
</div>
|
||||
<code style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-primary)",
|
||||
wordBreak: "break-all",
|
||||
lineHeight: 1.5,
|
||||
display: "block",
|
||||
}}>
|
||||
{vapidPublic}
|
||||
</code>
|
||||
<div style={{ background: 'var(--surface-variant)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '10px 12px', marginBottom: 10 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.5px' }}>Public Key</div>
|
||||
<code style={{ fontSize: 11, color: 'var(--text-primary)', wordBreak: 'break-all', lineHeight: 1.5, display: 'block' }}>{vapidPublic}</code>
|
||||
</div>
|
||||
<span style={{ fontSize: 13, color: "var(--success)", display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--success)', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
Push notifications active
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 12 }}>
|
||||
No VAPID keys found. Generate keys to enable Web Push notifications — users can then receive alerts when the app is closed or in the background.
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
||||
No VAPID keys found. Generate keys to enable Web Push notifications.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showRegenWarning && (
|
||||
<div style={{
|
||||
background: "#fce8e6",
|
||||
border: "1px solid #f5c6c2",
|
||||
borderRadius: "var(--radius)",
|
||||
padding: "14px 16px",
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--error)", marginBottom: 8 }}>
|
||||
⚠️ Regenerate VAPID keys?
|
||||
<div style={{ background: '#fce8e6', border: '1px solid #f5c6c2', borderRadius: 'var(--radius)', padding: '14px 16px', marginBottom: 16 }}>
|
||||
<p style={{ fontSize: 13, fontWeight: 600, color: 'var(--error)', marginBottom: 8 }}>⚠️ Regenerate VAPID keys?</p>
|
||||
<p style={{ fontSize: 13, color: '#5c2c28', marginBottom: 12, lineHeight: 1.5 }}>
|
||||
Generating new keys will <strong>invalidate all existing push subscriptions</strong>. Users will need to re-enable notifications.
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: "#5c2c28", marginBottom: 12, lineHeight: 1.5 }}>
|
||||
Generating new keys will <strong>invalidate all existing push subscriptions</strong>. Every user will stop receiving push notifications immediately and will need to re-enable them by opening the app. This cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ background: "var(--error)", color: "white" }}
|
||||
onClick={doGenerate}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating ? "Generating…" : "Yes, regenerate keys"}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowRegenWarning(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={doGenerate} disabled={generating}>{generating ? 'Generating…' : 'Yes, regenerate keys'}</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowRegenWarning(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showRegenWarning && (
|
||||
<button className="btn btn-primary btn-sm" onClick={handleGenerateClick} disabled={generating}>
|
||||
{generating ? "Generating…" : vapidPublic ? "Regenerate Keys" : "Generate Keys"}
|
||||
{generating ? 'Generating…' : vapidPublic ? 'Regenerate Keys' : 'Generate Keys'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 12, lineHeight: 1.5 }}>
|
||||
Requires HTTPS. After generating, users will be prompted to enable notifications on their next visit. On iOS, the app must be installed to the home screen first.
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 12, lineHeight: 1.5 }}>
|
||||
Requires HTTPS. On iOS, the app must be installed to the home screen first.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -51,7 +51,7 @@ function formatTime(dateStr) {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onBranding, onGroupsUpdated, isMobile, onAbout, onHelp, onlineUserIds = new Set() }) {
|
||||
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onBranding, onGroupManager, onGroupsUpdated, isMobile, onAbout, onHelp, onlineUserIds = new Set(), features = {} }) {
|
||||
const { user, logout } = useAuth();
|
||||
const { connected } = useSocket();
|
||||
const toast = useToast();
|
||||
@@ -107,8 +107,10 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
onClick={() => onSelectGroup(group.id)}
|
||||
>
|
||||
<div className="group-icon-wrap">
|
||||
{group.is_direct && group.peer_avatar ? (
|
||||
{group.is_direct && group.peer_avatar && !group.is_managed ? (
|
||||
<img src={group.peer_avatar} alt={group.name} className="group-icon" style={{ objectFit: 'cover', padding: 0 }} />
|
||||
) : group.is_managed ? (
|
||||
<div className="group-icon" style={{ background: settings.color_avatar_dm || '#a142f4', borderRadius: 8, fontSize: 11, fontWeight: 700 }}>MG</div>
|
||||
) : (
|
||||
<div className="group-icon" style={{ background: group.type === 'public' ? (settings.color_avatar_public || '#1a73e8') : (settings.color_avatar_dm || '#a142f4') }}>
|
||||
{group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
|
||||
@@ -225,23 +227,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
Profile
|
||||
</button>
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onUsers(); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
|
||||
User Manager
|
||||
</button>
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onBranding && onBranding(); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 2a10 10 0 1 0 10 10"/><path d="M12 6V2M12 22v-4M6 12H2M22 12h-4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
|
||||
Branding
|
||||
</button>
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onOpenSettings(); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>
|
||||
Settings
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<hr className="divider" style={{ margin: '4px 0' }} />
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onHelp && onHelp(); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
||||
Help
|
||||
|
||||
@@ -13,6 +13,8 @@ import NewChatModal from '../components/NewChatModal.jsx';
|
||||
import GlobalBar from '../components/GlobalBar.jsx';
|
||||
import AboutModal from '../components/AboutModal.jsx';
|
||||
import HelpModal from '../components/HelpModal.jsx';
|
||||
import NavDrawer from '../components/NavDrawer.jsx';
|
||||
import GroupManagerModal from '../components/GroupManagerModal.jsx';
|
||||
import './Chat.css';
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
@@ -34,7 +36,9 @@ export default function Chat() {
|
||||
const [activeGroupId, setActiveGroupId] = useState(null);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadGroups, setUnreadGroups] = useState(new Map());
|
||||
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help'
|
||||
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager'
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [features, setFeatures] = useState({ branding: false, groupManager: false });
|
||||
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
@@ -65,6 +69,24 @@ export default function Chat() {
|
||||
|
||||
useEffect(() => { loadGroups(); }, [loadGroups]);
|
||||
|
||||
// Load feature flags on mount
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setFeatures({
|
||||
branding: settings.feature_branding === 'true',
|
||||
groupManager: settings.feature_group_manager === 'true',
|
||||
});
|
||||
}).catch(() => {});
|
||||
const handler = () => api.getSettings().then(({ settings }) => {
|
||||
setFeatures({
|
||||
branding: settings.feature_branding === 'true',
|
||||
groupManager: settings.feature_group_manager === 'true',
|
||||
});
|
||||
}).catch(() => {});
|
||||
window.addEventListener('jama:settings-changed', handler);
|
||||
return () => window.removeEventListener('jama:settings-changed', handler);
|
||||
}, []);
|
||||
|
||||
// Register / refresh push subscription
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
@@ -303,7 +325,7 @@ export default function Chat() {
|
||||
return (
|
||||
<div className="chat-layout">
|
||||
{/* Global top bar — spans full width on desktop, visible on mobile sidebar view */}
|
||||
<GlobalBar isMobile={isMobile} showSidebar={showSidebar} />
|
||||
<GlobalBar isMobile={isMobile} showSidebar={showSidebar} onBurger={() => setDrawerOpen(true)} />
|
||||
|
||||
<div className="chat-body">
|
||||
{(!isMobile || showSidebar) && (
|
||||
@@ -318,6 +340,8 @@ export default function Chat() {
|
||||
onUsers={() => setModal('users')}
|
||||
onSettings={() => setModal('settings')}
|
||||
onBranding={() => setModal('branding')}
|
||||
onGroupManager={() => setModal('groupmanager')}
|
||||
features={features}
|
||||
onGroupsUpdated={loadGroups}
|
||||
isMobile={isMobile}
|
||||
onAbout={() => setModal('about')}
|
||||
@@ -337,10 +361,21 @@ export default function Chat() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NavDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onMessages={() => { setDrawerOpen(false); }}
|
||||
onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
|
||||
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
|
||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||
onUsers={() => { setDrawerOpen(false); setModal('users'); }}
|
||||
features={features}
|
||||
/>
|
||||
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}
|
||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />}
|
||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||
{modal === 'groupmanager' && <GroupManagerModal onClose={() => setModal(null)} />}
|
||||
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
||||
|
||||
@@ -101,6 +101,14 @@ export const api = {
|
||||
getSettings: () => req('GET', '/settings'),
|
||||
updateAppName: (name) => req('PATCH', '/settings/app-name', { name }),
|
||||
updateColors: (body) => req('PATCH', '/settings/colors', body),
|
||||
registerCode: (code) => req('POST', '/settings/register', { code }),
|
||||
|
||||
// User groups (Group Manager)
|
||||
getUserGroups: () => req('GET', '/usergroups'),
|
||||
getUserGroup: (id) => req('GET', `/usergroups/${id}`),
|
||||
createUserGroup: (body) => req('POST', '/usergroups', body),
|
||||
updateUserGroup: (id, body) => req('PATCH', `/usergroups/${id}`, body),
|
||||
deleteUserGroup: (id) => req('DELETE', `/usergroups/${id}`),
|
||||
uploadLogo: (file) => {
|
||||
const form = new FormData(); form.append('logo', file);
|
||||
return req('POST', '/settings/logo', form);
|
||||
|
||||
Reference in New Issue
Block a user