v0.12.29 various bug fixes
This commit is contained in:
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(/mnt/c/Program Files/nodejs/npm.cmd run build)",
|
||||||
|
"Bash(cmd.exe /c \"npm run build\")",
|
||||||
|
"Bash(cmd.exe /c \"cd /d d:\\\\_projects\\\\gitea\\\\jama\\\\frontend && npm run build 2>&1\")",
|
||||||
|
"Bash(cmd.exe /c \"cd /d d:\\\\_projects\\\\gitea\\\\jama\\\\frontend && npm run build\")",
|
||||||
|
"Bash(powershell.exe -Command \"cd ''d:\\\\_projects\\\\gitea\\\\jama\\\\frontend''; & ''C:\\\\Program Files\\\\nodejs\\\\npm.cmd'' run build 2>&1\")",
|
||||||
|
"Bash(powershell.exe -Command \"Get-Command npm -ErrorAction SilentlyContinue; \\(Get-Command node -ErrorAction SilentlyContinue\\).Source\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-backend",
|
"name": "rosterchirp-backend",
|
||||||
"version": "0.12.28",
|
"version": "0.12.29",
|
||||||
"description": "RosterChirp backend server",
|
"description": "RosterChirp backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -40,26 +40,30 @@ router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Search users
|
// Search users
|
||||||
|
// When q is empty (full-list load by GroupManagerPage / NewChatModal) — return ALL active users,
|
||||||
|
// no LIMIT, so the complete roster is available for member-picker UIs.
|
||||||
|
// When q is non-empty (typed search / mention autocomplete) — keep LIMIT 10 for performance.
|
||||||
router.get('/search', authMiddleware, async (req, res) => {
|
router.get('/search', authMiddleware, async (req, res) => {
|
||||||
const { q, groupId } = req.query;
|
const { q, groupId } = req.query;
|
||||||
|
const isTyped = q && q.length > 0;
|
||||||
try {
|
try {
|
||||||
let users;
|
let users;
|
||||||
if (groupId) {
|
if (groupId) {
|
||||||
const group = await queryOne(req.schema, 'SELECT type, is_direct FROM groups WHERE id = $1', [parseInt(groupId)]);
|
const group = await queryOne(req.schema, 'SELECT type, is_direct FROM groups WHERE id = $1', [parseInt(groupId)]);
|
||||||
if (group && (group.type === 'private' || group.is_direct)) {
|
if (group && (group.type === 'private' || group.is_direct)) {
|
||||||
users = await query(req.schema,
|
users = await query(req.schema,
|
||||||
"SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) LIMIT 10",
|
`SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) ORDER BY u.name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
||||||
[parseInt(groupId), req.user.id, `%${q}%`]
|
[parseInt(groupId), req.user.id, `%${q}%`]
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
users = await query(req.schema,
|
users = await query(req.schema,
|
||||||
"SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND id!=$1 AND (name ILIKE $2 OR display_name ILIKE $2) LIMIT 10",
|
`SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND id!=$1 AND (name ILIKE $2 OR display_name ILIKE $2) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
||||||
[req.user.id, `%${q}%`]
|
[req.user.id, `%${q}%`]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
users = await query(req.schema,
|
users = await query(req.schema,
|
||||||
"SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) LIMIT 10",
|
`SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
||||||
[`%${q}%`]
|
[`%${q}%`]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.12.28}"
|
VERSION="${1:-0.12.29}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="rosterchirp"
|
IMAGE_NAME="rosterchirp"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-frontend",
|
"name": "rosterchirp-frontend",
|
||||||
"version": "0.12.28",
|
"version": "0.12.29",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -83,6 +83,9 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reply preview */
|
/* Reply preview */
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
import { useToast } from '../contexts/ToastContext.jsx';
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
import { api } from '../utils/api.js';
|
import { api } from '../utils/api.js';
|
||||||
import Avatar from './Avatar.jsx';
|
import Avatar from './Avatar.jsx';
|
||||||
|
|
||||||
|
const LS_FONT_KEY = 'rosterchirp_font_scale';
|
||||||
|
const MIN_SCALE = 0.8;
|
||||||
|
const MAX_SCALE = 2.0;
|
||||||
|
|
||||||
export default function ProfileModal({ onClose }) {
|
export default function ProfileModal({ onClose }) {
|
||||||
const { user, updateUser } = useAuth();
|
const { user, updateUser } = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768);
|
||||||
const [displayName, setDisplayName] = useState(user?.display_name || '');
|
const [displayName, setDisplayName] = useState(user?.display_name || '');
|
||||||
const [savedDisplayName, setSavedDisplayName] = useState(user?.display_name || '');
|
const [savedDisplayName, setSavedDisplayName] = useState(user?.display_name || '');
|
||||||
const [displayNameWarning, setDisplayNameWarning] = useState('');
|
const [displayNameWarning, setDisplayNameWarning] = useState('');
|
||||||
@@ -16,7 +21,7 @@ export default function ProfileModal({ onClose }) {
|
|||||||
const [newPw, setNewPw] = useState('');
|
const [newPw, setNewPw] = useState('');
|
||||||
const [confirmPw, setConfirmPw] = useState('');
|
const [confirmPw, setConfirmPw] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications'
|
const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications' | 'appearance'
|
||||||
const [pushTesting, setPushTesting] = useState(false);
|
const [pushTesting, setPushTesting] = useState(false);
|
||||||
const [pushResult, setPushResult] = useState(null);
|
const [pushResult, setPushResult] = useState(null);
|
||||||
const [notifPermission, setNotifPermission] = useState(
|
const [notifPermission, setNotifPermission] = useState(
|
||||||
@@ -25,6 +30,23 @@ export default function ProfileModal({ onClose }) {
|
|||||||
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
|
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
|
||||||
const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0);
|
const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0);
|
||||||
|
|
||||||
|
const savedScale = parseFloat(localStorage.getItem(LS_FONT_KEY));
|
||||||
|
const [fontScale, setFontScale] = useState(
|
||||||
|
(savedScale >= MIN_SCALE && savedScale <= MAX_SCALE) ? savedScale : 1.0
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => setIsMobile(window.innerWidth < 768);
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
return () => window.removeEventListener('resize', onResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyFontScale = (val) => {
|
||||||
|
setFontScale(val);
|
||||||
|
document.documentElement.style.setProperty('--font-scale', val);
|
||||||
|
localStorage.setItem(LS_FONT_KEY, val);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
if (displayNameWarning) return toast('Display name is already in use', 'error');
|
if (displayNameWarning) return toast('Display name is already in use', 'error');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -102,12 +124,27 @@ export default function ProfileModal({ onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs — select on mobile, buttons on desktop */}
|
||||||
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
{isMobile ? (
|
||||||
<button className={`btn btn-sm ${tab === 'profile' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('profile')}>Profile</button>
|
<select
|
||||||
<button className={`btn btn-sm ${tab === 'password' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('password')}>Change Password</button>
|
className="input"
|
||||||
<button className={`btn btn-sm ${tab === 'notifications' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setTab('notifications'); setPushResult(null); }}>Notifications</button>
|
value={tab}
|
||||||
</div>
|
onChange={e => { setTab(e.target.value); setPushResult(null); }}
|
||||||
|
style={{ marginBottom: 20 }}
|
||||||
|
>
|
||||||
|
<option value="profile">Profile</option>
|
||||||
|
<option value="password">Change Password</option>
|
||||||
|
<option value="notifications">Notifications</option>
|
||||||
|
<option value="appearance">Appearance</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
||||||
|
<button className={`btn btn-sm ${tab === 'profile' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('profile')}>Profile</button>
|
||||||
|
<button className={`btn btn-sm ${tab === 'password' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('password')}>Change Password</button>
|
||||||
|
<button className={`btn btn-sm ${tab === 'notifications' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setTab('notifications'); setPushResult(null); }}>Notifications</button>
|
||||||
|
<button className={`btn btn-sm ${tab === 'appearance' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('appearance')}>Appearance</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === 'profile' && (
|
{tab === 'profile' && (
|
||||||
<div className="flex-col gap-3">
|
<div className="flex-col gap-3">
|
||||||
@@ -287,6 +324,40 @@ export default function ProfileModal({ onClose }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'appearance' && (
|
||||||
|
<div className="flex-col gap-3">
|
||||||
|
<div className="flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Message Font Size</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', flexShrink: 0 }}>A</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={MIN_SCALE}
|
||||||
|
max={MAX_SCALE}
|
||||||
|
step={0.05}
|
||||||
|
value={fontScale}
|
||||||
|
onChange={e => applyFontScale(parseFloat(e.target.value))}
|
||||||
|
style={{ flex: 1, accentColor: 'var(--primary)' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 18, color: 'var(--text-tertiary)', flexShrink: 0 }}>A</span>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 40, textAlign: 'right', flexShrink: 0 }}>
|
||||||
|
{Math.round(fontScale * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
Pinch to zoom in the chat window also adjusts this setting.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
style={{ alignSelf: 'flex-start' }}
|
||||||
|
onClick={() => applyFontScale(1.0)}
|
||||||
|
>
|
||||||
|
Reset to Default
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,6 +50,16 @@ function parseCSV(text, ignoreFirstRow, allUserGroups) {
|
|||||||
return { rows, invalid };
|
return { rows, invalid };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtLastLogin(ts) {
|
||||||
|
if (!ts) return 'Never';
|
||||||
|
const d = new Date(ts); const today = new Date(); today.setHours(0,0,0,0);
|
||||||
|
const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const dd = new Date(d); dd.setHours(0,0,0,0);
|
||||||
|
if (dd >= today) return 'Today';
|
||||||
|
if (dd >= yesterday) return 'Yesterday';
|
||||||
|
return dd.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
// ── User Row (accordion list item) ───────────────────────────────────────────
|
// ── User Row (accordion list item) ───────────────────────────────────────────
|
||||||
function UserRow({ u, onUpdated, onEdit }) {
|
function UserRow({ u, onUpdated, onEdit }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -94,15 +104,23 @@ function UserRow({ u, onUpdated, onEdit }) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && !u.is_default_admin && (
|
{open && !u.is_default_admin && (
|
||||||
<div style={{ padding:'6px 12px 12px', display:'flex', alignItems:'center', gap:8 }}>
|
<div style={{ padding:'6px 12px 12px', display:'flex', flexDirection:'column', gap:8 }}>
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => { setOpen(false); onEdit(u); }}>Edit User</button>
|
<div style={{ display:'flex', alignItems:'center', gap:14, flexWrap:'wrap', fontSize:12, color:'var(--text-tertiary)', paddingBottom:6, borderBottom:'1px solid var(--border)' }}>
|
||||||
<div style={{ marginLeft:'auto', display:'flex', gap:8 }}>
|
<span>Last Login: <strong style={{ color:'var(--text-secondary)' }}>{fmtLastLogin(u.last_online)}</strong></span>
|
||||||
{u.status === 'active' ? (
|
{!!u.must_change_password && (
|
||||||
<button className="btn btn-sm" style={{ background:'var(--warning)', color:'white' }} onClick={handleSuspend}>Suspend</button>
|
<span style={{ color:'var(--warning)', fontWeight:600 }}>⚠ Must change password</span>
|
||||||
) : u.status === 'suspended' ? (
|
)}
|
||||||
<button className="btn btn-sm" style={{ background:'var(--success)', color:'white' }} onClick={handleActivate}>Activate</button>
|
</div>
|
||||||
) : null}
|
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
||||||
<button className="btn btn-danger btn-sm" onClick={handleDelete}>Delete</button>
|
<button className="btn btn-primary btn-sm" onClick={() => { setOpen(false); onEdit(u); }}>Edit User</button>
|
||||||
|
<div style={{ marginLeft:'auto', display:'flex', gap:8 }}>
|
||||||
|
{u.status === 'active' ? (
|
||||||
|
<button className="btn btn-sm" style={{ background:'var(--warning)', color:'white' }} onClick={handleSuspend}>Suspend</button>
|
||||||
|
) : u.status === 'suspended' ? (
|
||||||
|
<button className="btn btn-sm" style={{ background:'var(--success)', color:'white' }} onClick={handleActivate}>Activate</button>
|
||||||
|
) : null}
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={handleDelete}>Delete</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -139,16 +157,6 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [isEdit, user?.id]);
|
}, [isEdit, user?.id]);
|
||||||
|
|
||||||
const fmtLastLogin = (ts) => {
|
|
||||||
if (!ts) return 'Never';
|
|
||||||
const d = new Date(ts); const today = new Date(); today.setHours(0,0,0,0);
|
|
||||||
const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
const dd = new Date(d); dd.setHours(0,0,0,0);
|
|
||||||
if (dd >= today) return 'Today';
|
|
||||||
if (dd >= yesterday) return 'Yesterday';
|
|
||||||
return dd.toISOString().slice(0, 10);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!isEdit && (!email.trim() || !isValidEmail(email.trim())))
|
if (!isEdit && (!email.trim() || !isValidEmail(email.trim())))
|
||||||
return toast('Valid email address required', 'error');
|
return toast('Valid email address required', 'error');
|
||||||
@@ -293,7 +301,7 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
|
|||||||
{allUserGroups?.length > 0 && (
|
{allUserGroups?.length > 0 && (
|
||||||
<div style={{ marginBottom:12 }}>
|
<div style={{ marginBottom:12 }}>
|
||||||
{lbl('User Groups', false, '(optional)')}
|
{lbl('User Groups', false, '(optional)')}
|
||||||
<div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', maxHeight:160, overflowY:'auto', marginTop:6 }}>
|
<div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', maxHeight:120, overflowY:'auto', marginTop:6 }}>
|
||||||
{allUserGroups.map(g => (
|
{allUserGroups.map(g => (
|
||||||
<label key={g.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'7px 10px', borderBottom:'1px solid var(--border)', cursor:'pointer' }}>
|
<label key={g.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'7px 10px', borderBottom:'1px solid var(--border)', cursor:'pointer' }}>
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
|
|||||||
Reference in New Issue
Block a user