From 13e5e3a6278bffabdb0fb1c6df4434a39f80bf20 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Thu, 26 Mar 2026 09:46:35 -0400 Subject: [PATCH] v0.12.29 various bug fixes --- .claude/settings.local.json | 12 ++++ backend/package.json | 2 +- backend/src/routes/users.js | 10 ++- build.sh | 2 +- frontend/package.json | 2 +- frontend/src/components/Message.css | 3 + frontend/src/components/ProfileModal.jsx | 87 +++++++++++++++++++++--- frontend/src/pages/UserManagerPage.jsx | 48 +++++++------ 8 files changed, 132 insertions(+), 34 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..d26fa39 --- /dev/null +++ b/.claude/settings.local.json @@ -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\")" + ] + } +} diff --git a/backend/package.json b/backend/package.json index ffb4d7b..6c42091 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.28", + "version": "0.12.29", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index f375305..3a980ec 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -40,26 +40,30 @@ router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => { }); // 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) => { const { q, groupId } = req.query; + const isTyped = q && q.length > 0; try { let users; if (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)) { 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}%`] ); } else { 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}%`] ); } } else { 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}%`] ); } diff --git a/build.sh b/build.sh index 9e6f4dc..3971e3f 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.28}" +VERSION="${1:-0.12.29}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index 19b1eed..d5391f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.28", + "version": "0.12.29", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/Message.css b/frontend/src/components/Message.css index aa59c8a..a441d09 100644 --- a/frontend/src/components/Message.css +++ b/frontend/src/components/Message.css @@ -83,6 +83,9 @@ color: var(--text-secondary); margin-bottom: 3px; padding: 0 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } /* Reply preview */ diff --git a/frontend/src/components/ProfileModal.jsx b/frontend/src/components/ProfileModal.jsx index 34cb13f..815d32c 100644 --- a/frontend/src/components/ProfileModal.jsx +++ b/frontend/src/components/ProfileModal.jsx @@ -1,13 +1,18 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useAuth } from '../contexts/AuthContext.jsx'; import { useToast } from '../contexts/ToastContext.jsx'; import { api } from '../utils/api.js'; 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 }) { const { user, updateUser } = useAuth(); const toast = useToast(); + const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768); const [displayName, setDisplayName] = useState(user?.display_name || ''); const [savedDisplayName, setSavedDisplayName] = useState(user?.display_name || ''); const [displayNameWarning, setDisplayNameWarning] = useState(''); @@ -16,7 +21,7 @@ export default function ProfileModal({ onClose }) { const [newPw, setNewPw] = useState(''); const [confirmPw, setConfirmPw] = useState(''); 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 [pushResult, setPushResult] = useState(null); const [notifPermission, setNotifPermission] = useState( @@ -25,6 +30,23 @@ export default function ProfileModal({ onClose }) { const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag); 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 () => { if (displayNameWarning) return toast('Display name is already in use', 'error'); setLoading(true); @@ -102,12 +124,27 @@ export default function ProfileModal({ onClose }) { - {/* Tabs */} -
- - - -
+ {/* Tabs — select on mobile, buttons on desktop */} + {isMobile ? ( + + ) : ( +
+ + + + +
+ )} {tab === 'profile' && (
@@ -287,6 +324,40 @@ export default function ProfileModal({ onClose }) {
)} + + {tab === 'appearance' && ( +
+
+ +
+ A + applyFontScale(parseFloat(e.target.value))} + style={{ flex: 1, accentColor: 'var(--primary)' }} + /> + A + + {Math.round(fontScale * 100)}% + +
+ + Pinch to zoom in the chat window also adjusts this setting. + +
+ +
+ )} ); diff --git a/frontend/src/pages/UserManagerPage.jsx b/frontend/src/pages/UserManagerPage.jsx index 2022479..1e57455 100644 --- a/frontend/src/pages/UserManagerPage.jsx +++ b/frontend/src/pages/UserManagerPage.jsx @@ -50,6 +50,16 @@ function parseCSV(text, ignoreFirstRow, allUserGroups) { 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) ─────────────────────────────────────────── function UserRow({ u, onUpdated, onEdit }) { const toast = useToast(); @@ -94,15 +104,23 @@ function UserRow({ u, onUpdated, onEdit }) { {open && !u.is_default_admin && ( -
- -
- {u.status === 'active' ? ( - - ) : u.status === 'suspended' ? ( - - ) : null} - +
+
+ Last Login: {fmtLastLogin(u.last_online)} + {!!u.must_change_password && ( + ⚠ Must change password + )} +
+
+ +
+ {u.status === 'active' ? ( + + ) : u.status === 'suspended' ? ( + + ) : null} + +
)} @@ -139,16 +157,6 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o .catch(() => {}); }, [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 () => { if (!isEdit && (!email.trim() || !isValidEmail(email.trim()))) return toast('Valid email address required', 'error'); @@ -293,7 +301,7 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o {allUserGroups?.length > 0 && (
{lbl('User Groups', false, '(optional)')} -
+
{allUserGroups.map(g => (