Phase 17: UI Optimization & Polish #24
@@ -44,7 +44,7 @@
|
||||
* - /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 { useQuery } from '@tanstack/react-query';
|
||||
import { CalendarShell } from './components/CalendarShell.js';
|
||||
@@ -59,12 +59,9 @@ import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
|
||||
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
|
||||
import { SetupBanner } from './components/SetupBanner.js';
|
||||
import { SettingsSheet } from './components/SettingsSheet.js';
|
||||
import { useIsPhone } from './hooks/useIsPhone.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.
|
||||
*
|
||||
@@ -75,13 +72,18 @@ function isPhone(): boolean {
|
||||
* which browsers cannot follow as a fetch/XHR (T-07-04).
|
||||
*/
|
||||
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');
|
||||
}, []);
|
||||
return <div aria-hidden="true" />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
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).
|
||||
// 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 { SkeletonCalendar } from './SkeletonCalendar.js';
|
||||
import { InstallPrompt } from './InstallPrompt.js';
|
||||
import { useIsPhone } from '../hooks/useIsPhone.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -71,10 +72,6 @@ function resolveDefaultView(persistedView: string): string {
|
||||
return persistedView;
|
||||
}
|
||||
|
||||
function isPhone(): boolean {
|
||||
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function CalendarShell() {
|
||||
@@ -249,7 +246,7 @@ export function CalendarShell() {
|
||||
const isInitialLoading = eventsQuery.isLoading && !eventsQuery.data;
|
||||
const isEventsError = eventsQuery.isError;
|
||||
|
||||
const phone = isPhone();
|
||||
const phone = useIsPhone();
|
||||
|
||||
// ── Auth splash (D-10) ────────────────────────────────────────────────────
|
||||
// Gate the calendar render on auth state so no calendar shell, skeleton, or
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
type SaveCredentialPayload,
|
||||
type SaveMyCredentialPayload,
|
||||
} from '../api/client.js';
|
||||
import { useIsPhone } from '../hooks/useIsPhone.js';
|
||||
|
||||
export type CredentialSheetMode = 'admin-rotate' | 'admin-add' | 'self-service';
|
||||
|
||||
@@ -76,6 +77,7 @@ export function CredentialSheet({
|
||||
triggerRef,
|
||||
}: CredentialSheetProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const phone = useIsPhone();
|
||||
const [password, setPassword] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
@@ -153,7 +155,6 @@ export function CredentialSheet({
|
||||
const heading = headingFor(mode);
|
||||
const isPending = credentialMutation.isPending;
|
||||
const saveDisabled = isPending || password.trim().length === 0 || email.trim().length === 0;
|
||||
const phone = window.matchMedia('(max-width: 767px)').matches;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { X, Bell, AlertCircle, Loader2, LogOut } from 'lucide-react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { usePushSubscription } from '../hooks/usePushSubscription.js';
|
||||
import { useIsPhone } from '../hooks/useIsPhone.js';
|
||||
import { InstructionSheet } from './InstructionSheet.js';
|
||||
import { fetchMe, fetchAuthMode, fetchChangePassword, fetchLinkOidc, fetchLocalLogout } from '../api/client.js';
|
||||
|
||||
@@ -55,6 +56,8 @@ interface SettingsSheetProps {
|
||||
export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
||||
const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription();
|
||||
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 [instructionsOpen, setInstructionsOpen] = useState(false);
|
||||
|
||||
@@ -132,8 +135,6 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
||||
|
||||
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
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
@@ -589,6 +590,7 @@ interface ChangePasswordSheetProps {
|
||||
}
|
||||
|
||||
function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) {
|
||||
const phone = useIsPhone();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
@@ -647,8 +649,6 @@ function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) {
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const phone = window.matchMedia('(max-width: 767px)').matches;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
@@ -921,6 +921,7 @@ interface LinkOidcSheetProps {
|
||||
}
|
||||
|
||||
function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
|
||||
const phone = useIsPhone();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
@@ -959,8 +960,6 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const phone = window.matchMedia('(max-width: 767px)').matches;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 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,
|
||||
} from '../api/client.js';
|
||||
import { CredentialSheet, type CredentialSheetMode } from '../components/CredentialSheet.js';
|
||||
import { useIsPhone } from '../hooks/useIsPhone.js';
|
||||
|
||||
// ── Styles ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -55,8 +56,8 @@ const sectionLabelStyle: React.CSSProperties = {
|
||||
export function AdminPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Phone detection for toast bottom offset
|
||||
const phone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||
// Phone detection for toast bottom offset (WR-05: resize-aware)
|
||||
const phone = useIsPhone();
|
||||
|
||||
// Success toast state (D-08)
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
@@ -1345,6 +1346,8 @@ interface ResetPasswordSheetProps {
|
||||
}
|
||||
|
||||
function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswordSheetProps) {
|
||||
// WR-05: resize-aware phone detection.
|
||||
const sheetPhone = useIsPhone();
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -1374,10 +1377,6 @@ function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswor
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Phone detection for desktop centering
|
||||
const sheetPhone =
|
||||
typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (newPassword !== confirmPassword) throw new Error('mismatch');
|
||||
|
||||
Reference in New Issue
Block a user