v0.12.32 bug fixes
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-backend",
|
"name": "rosterchirp-backend",
|
||||||
"version": "0.12.31",
|
"version": "0.12.32",
|
||||||
"description": "RosterChirp backend server",
|
"description": "RosterChirp backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.12.31}"
|
VERSION="${1:-0.12.32}"
|
||||||
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.31",
|
"version": "0.12.32",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -193,143 +193,6 @@ function RegistrationTab({ onFeaturesChanged }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Push Debug Tab ────────────────────────────────────────────────────────────
|
|
||||||
function DebugRow({ label, value, ok, bad }) {
|
|
||||||
const color = ok ? 'var(--success)' : bad ? 'var(--error)' : 'var(--text-secondary)';
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>{label}</span>
|
|
||||||
<span style={{ color, fontFamily: 'monospace', fontSize: 12 }}>{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PushDebugTab() {
|
|
||||||
const toast = useToast();
|
|
||||||
const [debugData, setDebugData] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [testing, setTesting] = useState(false);
|
|
||||||
|
|
||||||
const permission = (typeof Notification !== 'undefined') ? Notification.permission : 'unsupported';
|
|
||||||
const [cachedToken, setCachedToken] = useState(localStorage.getItem('rc_fcm_token'));
|
|
||||||
const [lastError, setLastError] = useState(localStorage.getItem('rc_fcm_error'));
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await api.pushDebug();
|
|
||||||
setDebugData(data);
|
|
||||||
} catch (e) {
|
|
||||||
toast(e.message || 'Failed to load debug data', 'error');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
|
||||||
|
|
||||||
const doTest = async (mode) => {
|
|
||||||
setTesting(true);
|
|
||||||
try {
|
|
||||||
const result = await api.testPush(mode);
|
|
||||||
const sent = result.results?.find(r => r.status === 'sent');
|
|
||||||
const failed = result.results?.find(r => r.status === 'failed');
|
|
||||||
if (sent) toast(`Test sent (mode=${mode}) — check device for notification`, 'success');
|
|
||||||
else if (failed) toast(`Test failed: ${failed.error}`, 'error');
|
|
||||||
else toast('No subscription found — grant permission and reload', 'error');
|
|
||||||
} catch (e) {
|
|
||||||
toast(e.message || 'Test failed', 'error');
|
|
||||||
} finally {
|
|
||||||
setTesting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearToken = () => {
|
|
||||||
localStorage.removeItem('rc_fcm_token');
|
|
||||||
localStorage.removeItem('rc_fcm_error');
|
|
||||||
setCachedToken(null);
|
|
||||||
setLastError(null);
|
|
||||||
toast('Cached token cleared — reload to re-register with server', 'info');
|
|
||||||
};
|
|
||||||
|
|
||||||
const reregister = () => {
|
|
||||||
localStorage.removeItem('rc_fcm_token');
|
|
||||||
localStorage.removeItem('rc_fcm_error');
|
|
||||||
setCachedToken(null);
|
|
||||||
setLastError(null);
|
|
||||||
window.dispatchEvent(new CustomEvent('rosterchirp:push-init'));
|
|
||||||
toast('Re-registering push subscription…', 'info');
|
|
||||||
};
|
|
||||||
|
|
||||||
const box = { background: 'var(--surface-variant)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '12px 14px', marginBottom: 14 };
|
|
||||||
const sectionLabel = { fontSize: 11, fontWeight: 600, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 8 };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="settings-section-label" style={{ marginBottom: 14 }}>Push Notification Debug</div>
|
|
||||||
|
|
||||||
{/* This device */}
|
|
||||||
<div style={box}>
|
|
||||||
<div style={sectionLabel}>This Device</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
|
|
||||||
<DebugRow label="Permission" value={permission} ok={permission === 'granted'} bad={permission === 'denied'} />
|
|
||||||
<DebugRow label="Cached FCM token" value={cachedToken ? cachedToken.slice(0, 36) + '…' : 'None'} ok={!!cachedToken} bad={!cachedToken} />
|
|
||||||
{debugData && <DebugRow label="FCM env vars" value={debugData.fcmConfigured ? 'Present' : 'Missing'} ok={debugData.fcmConfigured} bad={!debugData.fcmConfigured} />}
|
|
||||||
{debugData && <DebugRow label="Firebase Admin" value={debugData.firebaseAdminReady ? 'Ready' : 'Not ready'} ok={debugData.firebaseAdminReady} bad={!debugData.firebaseAdminReady} />}
|
|
||||||
{lastError && <DebugRow label="Last reg. error" value={lastError} bad={true} />}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<button className="btn btn-sm btn-primary" onClick={reregister}>Re-register</button>
|
|
||||||
<button className="btn btn-sm btn-secondary" onClick={clearToken}>Clear cached token</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Test push */}
|
|
||||||
<div style={box}>
|
|
||||||
<div style={sectionLabel}>Send Test Notification to This Device</div>
|
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-secondary)', marginBottom: 10, lineHeight: 1.5 }}>
|
|
||||||
<strong>notification</strong> — same path as real messages (SW <code>onBackgroundMessage</code>)<br/>
|
|
||||||
<strong>browser</strong> — Chrome shows it directly, bypasses the SW (confirm delivery works)
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<button className="btn btn-sm btn-primary" onClick={() => doTest('notification')} disabled={testing}>
|
|
||||||
{testing ? 'Sending…' : 'Test (notification)'}
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-sm btn-secondary" onClick={() => doTest('browser')} disabled={testing}>
|
|
||||||
{testing ? 'Sending…' : 'Test (browser)'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Registered devices */}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
|
||||||
<div className="settings-section-label" style={{ margin: 0 }}>Registered Devices</div>
|
|
||||||
<button className="btn btn-sm btn-secondary" onClick={load} disabled={loading}>{loading ? 'Loading…' : 'Refresh'}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-tertiary)' }}>Loading…</p>
|
|
||||||
) : !debugData?.subscriptions?.length ? (
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-tertiary)' }}>No FCM tokens registered.</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{debugData.subscriptions.map(sub => (
|
|
||||||
<div key={sub.id} style={box}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 600 }}>{sub.name || sub.email}</span>
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', background: 'var(--surface)', padding: '2px 7px', borderRadius: 4, border: '1px solid var(--border)' }}>{sub.device}</span>
|
|
||||||
</div>
|
|
||||||
<code style={{ fontSize: 10, color: 'var(--text-secondary)', wordBreak: 'break-all', lineHeight: 1.6, display: 'block' }}>
|
|
||||||
{sub.fcm_token}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main modal ────────────────────────────────────────────────────────────────
|
// ── Main modal ────────────────────────────────────────────────────────────────
|
||||||
export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
||||||
const [tab, setTab] = useState('registration');
|
const [tab, setTab] = useState('registration');
|
||||||
@@ -349,7 +212,6 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
isTeam && { id: 'team', label: 'Team Management' },
|
isTeam && { id: 'team', label: 'Team Management' },
|
||||||
{ id: 'registration', label: 'Registration' },
|
{ id: 'registration', label: 'Registration' },
|
||||||
{ id: 'pushdebug', label: 'Push Debug' },
|
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -373,7 +235,6 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
|||||||
|
|
||||||
{tab === 'team' && <TeamManagementTab />}
|
{tab === 'team' && <TeamManagementTab />}
|
||||||
{tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />}
|
{tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />}
|
||||||
{tab === 'pushdebug' && <PushDebugTab />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
import Avatar from './Avatar.jsx';
|
import Avatar from './Avatar.jsx';
|
||||||
|
|
||||||
function useTheme() {
|
function useTheme() {
|
||||||
@@ -41,6 +43,175 @@ function usePushToggle() {
|
|||||||
return { permitted, enabled, toggle };
|
return { permitted, enabled, toggle };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Debug helpers ─────────────────────────────────────────────────────────────
|
||||||
|
function DebugRow({ label, value, ok, bad }) {
|
||||||
|
const color = ok ? 'var(--success)' : bad ? 'var(--error)' : 'var(--text-secondary)';
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>{label}</span>
|
||||||
|
<span style={{ color, fontFamily: 'monospace', fontSize: 12 }}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test Notifications Modal ──────────────────────────────────────────────────
|
||||||
|
function TestNotificationsModal({ onClose }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [debugData, setDebugData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
|
||||||
|
const permission = (typeof Notification !== 'undefined') ? Notification.permission : 'unsupported';
|
||||||
|
const [cachedToken, setCachedToken] = useState(localStorage.getItem('rc_fcm_token'));
|
||||||
|
const [lastError, setLastError] = useState(localStorage.getItem('rc_fcm_error'));
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.pushDebug();
|
||||||
|
setDebugData(data);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || 'Failed to load debug data', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const doTest = async (mode) => {
|
||||||
|
setTesting(true);
|
||||||
|
try {
|
||||||
|
const result = await api.testPush(mode);
|
||||||
|
const sent = result.results?.find(r => r.status === 'sent');
|
||||||
|
const failed = result.results?.find(r => r.status === 'failed');
|
||||||
|
if (sent) toast(`Test sent (mode=${mode}) — check device for notification`, 'success');
|
||||||
|
else if (failed) toast(`Test failed: ${failed.error}`, 'error');
|
||||||
|
else toast('No subscription found — grant permission and reload', 'error');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || 'Test failed', 'error');
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearToken = () => {
|
||||||
|
localStorage.removeItem('rc_fcm_token');
|
||||||
|
localStorage.removeItem('rc_fcm_error');
|
||||||
|
setCachedToken(null);
|
||||||
|
setLastError(null);
|
||||||
|
toast('Cached token cleared — reload to re-register with server', 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const reregister = () => {
|
||||||
|
localStorage.removeItem('rc_fcm_token');
|
||||||
|
localStorage.removeItem('rc_fcm_error');
|
||||||
|
setCachedToken(null);
|
||||||
|
setLastError(null);
|
||||||
|
window.dispatchEvent(new CustomEvent('rosterchirp:push-init'));
|
||||||
|
toast('Re-registering push subscription…', 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const box = { background: 'var(--surface-variant)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '12px 14px', marginBottom: 14 };
|
||||||
|
const sectionLabel = { fontSize: 11, fontWeight: 600, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 8 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal" style={{ maxWidth: 520 }}>
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||||
|
<h2 className="modal-title" style={{ margin: 0 }}>Test Notifications</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>
|
||||||
|
|
||||||
|
{/* This device */}
|
||||||
|
<div style={box}>
|
||||||
|
<div style={sectionLabel}>This Device</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
|
||||||
|
<DebugRow label="Permission" value={permission} ok={permission === 'granted'} bad={permission === 'denied'} />
|
||||||
|
<DebugRow label="Cached FCM token" value={cachedToken ? cachedToken.slice(0, 36) + '…' : 'None'} ok={!!cachedToken} bad={!cachedToken} />
|
||||||
|
{debugData && <DebugRow label="FCM env vars" value={debugData.fcmConfigured ? 'Present' : 'Missing'} ok={debugData.fcmConfigured} bad={!debugData.fcmConfigured} />}
|
||||||
|
{debugData && <DebugRow label="Firebase Admin" value={debugData.firebaseAdminReady ? 'Ready' : 'Not ready'} ok={debugData.firebaseAdminReady} bad={!debugData.firebaseAdminReady} />}
|
||||||
|
{lastError && <DebugRow label="Last reg. error" value={lastError} bad={true} />}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button className="btn btn-sm btn-primary" onClick={reregister}>Re-register</button>
|
||||||
|
<button className="btn btn-sm btn-secondary" onClick={clearToken}>Clear cached token</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test push */}
|
||||||
|
<div style={box}>
|
||||||
|
<div style={sectionLabel}>Send Test Notification to This Device</div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-secondary)', marginBottom: 10, lineHeight: 1.5 }}>
|
||||||
|
<strong>notification</strong> — same path as real messages (SW <code>onBackgroundMessage</code>)<br/>
|
||||||
|
<strong>browser</strong> — Chrome shows it directly, bypasses the SW (confirm delivery works)
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button className="btn btn-sm btn-primary" onClick={() => doTest('notification')} disabled={testing}>
|
||||||
|
{testing ? 'Sending…' : 'Test (notification)'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm btn-secondary" onClick={() => doTest('browser')} disabled={testing}>
|
||||||
|
{testing ? 'Sending…' : 'Test (browser)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Registered devices */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||||
|
<div className="settings-section-label" style={{ margin: 0 }}>Registered Devices</div>
|
||||||
|
<button className="btn btn-sm btn-secondary" onClick={load} disabled={loading}>{loading ? 'Loading…' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-tertiary)' }}>Loading…</p>
|
||||||
|
) : !debugData?.subscriptions?.length ? (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-tertiary)' }}>No FCM tokens registered.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{debugData.subscriptions.map(sub => (
|
||||||
|
<div key={sub.id} style={box}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600 }}>{sub.name || sub.email}</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', background: 'var(--surface)', padding: '2px 7px', borderRadius: 4, border: '1px solid var(--border)' }}>{sub.device}</span>
|
||||||
|
</div>
|
||||||
|
<code style={{ fontSize: 10, color: 'var(--text-secondary)', wordBreak: 'break-all', lineHeight: 1.6, display: 'block' }}>
|
||||||
|
{sub.fcm_token}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Confirm Modal ─────────────────────────────────────────────────────────────
|
||||||
|
function ConfirmToggleModal({ enabling, onConfirm, onCancel }) {
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onCancel()}>
|
||||||
|
<div className="modal" style={{ maxWidth: 360 }}>
|
||||||
|
<h3 style={{ fontSize: 16, fontWeight: 700, margin: '0 0 12px' }}>
|
||||||
|
{enabling ? 'Enable Notifications' : 'Disable Notifications'}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--text-secondary)', marginBottom: 20, lineHeight: 1.5 }}>
|
||||||
|
{enabling
|
||||||
|
? 'Turn on push notifications for this device?'
|
||||||
|
: 'Turn off push notifications? You will no longer receive alerts on this device.'}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={onConfirm}>
|
||||||
|
{enabling ? 'Turn On' : 'Turn Off'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=false }) {
|
export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=false }) {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
@@ -48,6 +219,8 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f
|
|||||||
const { permitted: showPushToggle, enabled: pushEnabled, toggle: togglePush } = usePushToggle();
|
const { permitted: showPushToggle, enabled: pushEnabled, toggle: togglePush } = usePushToggle();
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
const btnRef = useRef(null);
|
const btnRef = useRef(null);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [showTestNotif, setShowTestNotif] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showMenu) return;
|
if (!showMenu) return;
|
||||||
@@ -63,6 +236,12 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f
|
|||||||
|
|
||||||
const handleLogout = async () => { await logout(); };
|
const handleLogout = async () => { await logout(); };
|
||||||
|
|
||||||
|
const handleToggleConfirm = () => {
|
||||||
|
togglePush();
|
||||||
|
setShowConfirm(false);
|
||||||
|
setShowMenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
if (mobileCompact) return (
|
if (mobileCompact) return (
|
||||||
<div style={{ position:'relative' }}>
|
<div style={{ position:'relative' }}>
|
||||||
<button ref={btnRef} onClick={() => setShowMenu(!showMenu)} style={{ background:'none',border:'none',cursor:'pointer',padding:2,display:'flex',alignItems:'center' }}>
|
<button ref={btnRef} onClick={() => setShowMenu(!showMenu)} style={{ background:'none',border:'none',cursor:'pointer',padding:2,display:'flex',alignItems:'center' }}>
|
||||||
@@ -76,10 +255,16 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f
|
|||||||
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button>
|
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button>
|
||||||
))}
|
))}
|
||||||
{showPushToggle && (
|
{showPushToggle && (
|
||||||
<button onClick={() => { togglePush(); setShowMenu(false); }} style={{ display:'flex',alignItems:'center',justifyContent:'space-between',width:'100%',padding:'11px 14px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)' }}
|
<button onClick={() => { setShowMenu(false); setShowConfirm(true); }} style={{ display:'flex',alignItems:'center',justifyContent:'space-between',width:'100%',padding:'11px 14px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)' }}
|
||||||
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>
|
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>
|
||||||
<span>Notifications</span>
|
<span>Notifications</span>
|
||||||
<span style={{ fontSize:12,fontWeight:600,color: pushEnabled ? 'var(--primary)' : 'var(--text-secondary)' }}>{pushEnabled ? 'ON' : 'OFF'}</span>
|
<span style={{ fontSize:12,fontWeight:700,color: pushEnabled ? '#22c55e' : '#ef4444' }}>{pushEnabled ? 'ON' : 'OFF'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showPushToggle && pushEnabled && (
|
||||||
|
<button onClick={() => { setShowMenu(false); setShowTestNotif(true); }} style={{ display:'block',width:'100%',padding:'11px 14px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--primary)' }}
|
||||||
|
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>
|
||||||
|
Test Notifications
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div style={{ borderTop:'1px solid var(--border)' }}>
|
<div style={{ borderTop:'1px solid var(--border)' }}>
|
||||||
@@ -87,6 +272,8 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showConfirm && <ConfirmToggleModal enabling={!pushEnabled} onConfirm={handleToggleConfirm} onCancel={() => setShowConfirm(false)} />}
|
||||||
|
{showTestNotif && <TestNotificationsModal onClose={() => setShowTestNotif(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -135,14 +322,20 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f
|
|||||||
About
|
About
|
||||||
</button>
|
</button>
|
||||||
{showPushToggle && (
|
{showPushToggle && (
|
||||||
<button className="footer-menu-item" onClick={() => { togglePush(); setShowMenu(false); }}>
|
<button className="footer-menu-item" onClick={() => { setShowMenu(false); setShowConfirm(true); }}>
|
||||||
{pushEnabled ? (
|
{pushEnabled ? (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||||
) : (
|
) : (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||||
)}
|
)}
|
||||||
<span style={{ flex: 1 }}>Notifications</span>
|
<span style={{ flex: 1 }}>Notifications</span>
|
||||||
<span style={{ fontSize: 11, fontWeight: 600, color: pushEnabled ? 'var(--primary)' : 'var(--text-secondary)' }}>{pushEnabled ? 'ON' : 'OFF'}</span>
|
<span style={{ fontSize: 11, fontWeight: 700, color: pushEnabled ? '#22c55e' : '#ef4444' }}>{pushEnabled ? 'ON' : 'OFF'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showPushToggle && pushEnabled && (
|
||||||
|
<button className="footer-menu-item" onClick={() => { setShowMenu(false); setShowTestNotif(true); }}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||||
|
Test Notifications
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<hr className="divider" style={{ margin: '4px 0' }} />
|
<hr className="divider" style={{ margin: '4px 0' }} />
|
||||||
@@ -152,6 +345,9 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showConfirm && <ConfirmToggleModal enabling={!pushEnabled} onConfirm={handleToggleConfirm} onCancel={() => setShowConfirm(false)} />}
|
||||||
|
{showTestNotif && <TestNotificationsModal onClose={() => setShowTestNotif(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import AboutModal from '../components/AboutModal.jsx';
|
|||||||
import HelpModal from '../components/HelpModal.jsx';
|
import HelpModal from '../components/HelpModal.jsx';
|
||||||
import NavDrawer from '../components/NavDrawer.jsx';
|
import NavDrawer from '../components/NavDrawer.jsx';
|
||||||
import SchedulePage from '../components/SchedulePage.jsx';
|
import SchedulePage from '../components/SchedulePage.jsx';
|
||||||
|
import MobileGroupManager from '../components/MobileGroupManager.jsx';
|
||||||
import './Chat.css';
|
import './Chat.css';
|
||||||
|
|
||||||
function urlBase64ToUint8Array(base64String) {
|
function urlBase64ToUint8Array(base64String) {
|
||||||
|
|||||||
Reference in New Issue
Block a user