fix(17): WR-05 IN-03 IN-01 resize-aware useIsPhone hook; OidcRedirect side-effect in effect
This commit is contained in:
@@ -44,7 +44,7 @@
|
|||||||
* - /api/me query shared so AppNav has user data on all routes.
|
* - /api/me query shared so AppNav has user data on all routes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { CalendarShell } from './components/CalendarShell.js';
|
import { CalendarShell } from './components/CalendarShell.js';
|
||||||
@@ -59,12 +59,9 @@ import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
|
|||||||
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
|
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
|
||||||
import { SetupBanner } from './components/SetupBanner.js';
|
import { SetupBanner } from './components/SetupBanner.js';
|
||||||
import { SettingsSheet } from './components/SettingsSheet.js';
|
import { SettingsSheet } from './components/SettingsSheet.js';
|
||||||
|
import { useIsPhone } from './hooks/useIsPhone.js';
|
||||||
import { fetchMe, fetchSetupStatus, fetchAuthMode } from './api/client.js';
|
import { fetchMe, fetchSetupStatus, fetchAuthMode } from './api/client.js';
|
||||||
|
|
||||||
function isPhone(): boolean {
|
|
||||||
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OidcRedirect — tiny helper that triggers a top-level navigation to /api/login.
|
* OidcRedirect — tiny helper that triggers a top-level navigation to /api/login.
|
||||||
*
|
*
|
||||||
@@ -75,13 +72,18 @@ function isPhone(): boolean {
|
|||||||
* which browsers cannot follow as a fetch/XHR (T-07-04).
|
* which browsers cannot follow as a fetch/XHR (T-07-04).
|
||||||
*/
|
*/
|
||||||
function OidcRedirect() {
|
function OidcRedirect() {
|
||||||
|
// IN-01: navigate from an effect, not during render. React may invoke a
|
||||||
|
// component body more than once (StrictMode double-invoke, concurrent
|
||||||
|
// re-renders); side effects belong in useEffect.
|
||||||
|
useEffect(() => {
|
||||||
window.location.replace('/api/login');
|
window.location.replace('/api/login');
|
||||||
|
}, []);
|
||||||
return <div aria-hidden="true" />;
|
return <div aria-hidden="true" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const phone = isPhone();
|
const phone = useIsPhone();
|
||||||
|
|
||||||
// Setup status query — staleTime 0 so the wizard gate is always fresh (D-10 spirit).
|
// Setup status query — staleTime 0 so the wizard gate is always fresh (D-10 spirit).
|
||||||
// Must be fetched before rendering any authenticated route to gate the app on /setup.
|
// Must be fetched before rendering any authenticated route to gate the app on /setup.
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import { SyncStateToast } from './SyncStateToast.js';
|
|||||||
import { ColorLegend } from './ColorLegend.js';
|
import { ColorLegend } from './ColorLegend.js';
|
||||||
import { SkeletonCalendar } from './SkeletonCalendar.js';
|
import { SkeletonCalendar } from './SkeletonCalendar.js';
|
||||||
import { InstallPrompt } from './InstallPrompt.js';
|
import { InstallPrompt } from './InstallPrompt.js';
|
||||||
|
import { useIsPhone } from '../hooks/useIsPhone.js';
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -71,10 +72,6 @@ function resolveDefaultView(persistedView: string): string {
|
|||||||
return persistedView;
|
return persistedView;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPhone(): boolean {
|
|
||||||
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Component ──────────────────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function CalendarShell() {
|
export function CalendarShell() {
|
||||||
@@ -249,7 +246,7 @@ export function CalendarShell() {
|
|||||||
const isInitialLoading = eventsQuery.isLoading && !eventsQuery.data;
|
const isInitialLoading = eventsQuery.isLoading && !eventsQuery.data;
|
||||||
const isEventsError = eventsQuery.isError;
|
const isEventsError = eventsQuery.isError;
|
||||||
|
|
||||||
const phone = isPhone();
|
const phone = useIsPhone();
|
||||||
|
|
||||||
// ── Auth splash (D-10) ────────────────────────────────────────────────────
|
// ── Auth splash (D-10) ────────────────────────────────────────────────────
|
||||||
// Gate the calendar render on auth state so no calendar shell, skeleton, or
|
// Gate the calendar render on auth state so no calendar shell, skeleton, or
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
type SaveCredentialPayload,
|
type SaveCredentialPayload,
|
||||||
type SaveMyCredentialPayload,
|
type SaveMyCredentialPayload,
|
||||||
} from '../api/client.js';
|
} from '../api/client.js';
|
||||||
|
import { useIsPhone } from '../hooks/useIsPhone.js';
|
||||||
|
|
||||||
export type CredentialSheetMode = 'admin-rotate' | 'admin-add' | 'self-service';
|
export type CredentialSheetMode = 'admin-rotate' | 'admin-add' | 'self-service';
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ export function CredentialSheet({
|
|||||||
triggerRef,
|
triggerRef,
|
||||||
}: CredentialSheetProps) {
|
}: CredentialSheetProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const phone = useIsPhone();
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
@@ -153,7 +155,6 @@ export function CredentialSheet({
|
|||||||
const heading = headingFor(mode);
|
const heading = headingFor(mode);
|
||||||
const isPending = credentialMutation.isPending;
|
const isPending = credentialMutation.isPending;
|
||||||
const saveDisabled = isPending || password.trim().length === 0 || email.trim().length === 0;
|
const saveDisabled = isPending || password.trim().length === 0 || email.trim().length === 0;
|
||||||
const phone = window.matchMedia('(max-width: 767px)').matches;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { X, Bell, AlertCircle, Loader2, LogOut } from 'lucide-react';
|
|||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { usePushSubscription } from '../hooks/usePushSubscription.js';
|
import { usePushSubscription } from '../hooks/usePushSubscription.js';
|
||||||
|
import { useIsPhone } from '../hooks/useIsPhone.js';
|
||||||
import { InstructionSheet } from './InstructionSheet.js';
|
import { InstructionSheet } from './InstructionSheet.js';
|
||||||
import { fetchMe, fetchAuthMode, fetchChangePassword, fetchLinkOidc, fetchLocalLogout } from '../api/client.js';
|
import { fetchMe, fetchAuthMode, fetchChangePassword, fetchLinkOidc, fetchLocalLogout } from '../api/client.js';
|
||||||
|
|
||||||
@@ -55,6 +56,8 @@ interface SettingsSheetProps {
|
|||||||
export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
||||||
const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription();
|
const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
// WR-05: resize-aware so an iPad rotation across 767px reflows the sheet.
|
||||||
|
const phone = useIsPhone();
|
||||||
const [isTogglingOn, setIsTogglingOn] = useState(false);
|
const [isTogglingOn, setIsTogglingOn] = useState(false);
|
||||||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||||||
|
|
||||||
@@ -132,8 +135,6 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const phone = window.matchMedia('(max-width: 767px)').matches;
|
|
||||||
|
|
||||||
// D-07: fire-and-best-effort logout — navigate to /login regardless of API success/failure
|
// D-07: fire-and-best-effort logout — navigate to /login regardless of API success/failure
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -589,6 +590,7 @@ interface ChangePasswordSheetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) {
|
function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) {
|
||||||
|
const phone = useIsPhone();
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
@@ -647,8 +649,6 @@ function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) {
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const phone = window.matchMedia('(max-width: 767px)').matches;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
@@ -921,6 +921,7 @@ interface LinkOidcSheetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
|
function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
|
||||||
|
const phone = useIsPhone();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||||
|
|
||||||
@@ -959,8 +960,6 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const phone = window.matchMedia('(max-width: 767px)').matches;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* useIsPhone / useMediaQuery — resize-aware breakpoint hooks (WR-05, IN-03).
|
||||||
|
*
|
||||||
|
* The previous pattern read `window.matchMedia('(max-width: 767px)').matches`
|
||||||
|
* synchronously at render time with no `change` listener, so rotating an iPad
|
||||||
|
* across the 767px breakpoint (or resizing a desktop window) left the layout
|
||||||
|
* stale until an unrelated re-render. These hooks subscribe to the media query
|
||||||
|
* via `matchMedia.addEventListener('change', …)` so components re-render on
|
||||||
|
* breakpoint crossings.
|
||||||
|
*
|
||||||
|
* The phone breakpoint is centralised here (PHONE_MAX_QUERY) so the JS constant
|
||||||
|
* stays in one place. It corresponds to one pixel below `--bp-tablet: 768px`
|
||||||
|
* in tokens.css.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/** Phone breakpoint: ≤767px (one below --bp-tablet: 768px). */
|
||||||
|
export const PHONE_MAX_QUERY = '(max-width: 767px)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useMediaQuery — subscribe to a media query and re-render on change.
|
||||||
|
*
|
||||||
|
* SSR-safe: returns `false` when `window`/`matchMedia` is unavailable.
|
||||||
|
*/
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState<boolean>(() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
||||||
|
return window.matchMedia(query).matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||||
|
const mql = window.matchMedia(query);
|
||||||
|
// Sync immediately in case the query changed between render and effect.
|
||||||
|
setMatches(mql.matches);
|
||||||
|
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||||
|
mql.addEventListener('change', onChange);
|
||||||
|
return () => mql.removeEventListener('change', onChange);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useIsPhone — true when the viewport is at or below the phone breakpoint.
|
||||||
|
* Re-renders on breakpoint crossings (resize / orientation change).
|
||||||
|
*/
|
||||||
|
export function useIsPhone(): boolean {
|
||||||
|
return useMediaQuery(PHONE_MAX_QUERY);
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
type AdminCalendar,
|
type AdminCalendar,
|
||||||
} from '../api/client.js';
|
} from '../api/client.js';
|
||||||
import { CredentialSheet, type CredentialSheetMode } from '../components/CredentialSheet.js';
|
import { CredentialSheet, type CredentialSheetMode } from '../components/CredentialSheet.js';
|
||||||
|
import { useIsPhone } from '../hooks/useIsPhone.js';
|
||||||
|
|
||||||
// ── Styles ─────────────────────────────────────────────────────────────────
|
// ── Styles ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -55,8 +56,8 @@ const sectionLabelStyle: React.CSSProperties = {
|
|||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Phone detection for toast bottom offset
|
// Phone detection for toast bottom offset (WR-05: resize-aware)
|
||||||
const phone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
const phone = useIsPhone();
|
||||||
|
|
||||||
// Success toast state (D-08)
|
// Success toast state (D-08)
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
@@ -1345,6 +1346,8 @@ interface ResetPasswordSheetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswordSheetProps) {
|
function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswordSheetProps) {
|
||||||
|
// WR-05: resize-aware phone detection.
|
||||||
|
const sheetPhone = useIsPhone();
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -1374,10 +1377,6 @@ function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswor
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phone detection for desktop centering
|
|
||||||
const sheetPhone =
|
|
||||||
typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
|
||||||
|
|
||||||
const resetMutation = useMutation({
|
const resetMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (newPassword !== confirmPassword) throw new Error('mismatch');
|
if (newPassword !== confirmPassword) throw new Error('mismatch');
|
||||||
|
|||||||
Reference in New Issue
Block a user