Stage 6 Phase 2-3: LockScreen rewrite + SettingsPage restructure
- LockScreen: full rewrite — username/password auth (setup/login/TOTP states), ambient glow blobs, UMBRA wordmark in flex flow, animate-slide-up card, HTTP 423 lockout banner, Loader2 spinner, client-side password validation - SettingsPage: two-column lg grid (Profile/Appearance/Weather left, Calendar/Dashboard right), fixed h-16 page header, icon-anchored CardHeaders, labeled accent swatch grid with aria-pressed, max-w-xs removed from name input, upcoming days onBlur save with NaN+no-op guard, Security card removed - useSettings: remove deprecated changePin/isChangingPin stubs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
67456c78dd
commit
5a8819c4a5
@ -1,115 +1,282 @@
|
|||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { useNavigate, Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Lock } from 'lucide-react';
|
import { Lock, Loader2 } from 'lucide-react';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { getErrorMessage } from '@/lib/api';
|
import { getErrorMessage } from '@/lib/api';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/** Validates password against backend rules: 12-128 chars, at least one letter + one non-letter. */
|
||||||
|
function validatePassword(password: string): string | null {
|
||||||
|
if (password.length < 12) return 'Password must be at least 12 characters';
|
||||||
|
if (password.length > 128) return 'Password must be at most 128 characters';
|
||||||
|
if (!/[a-zA-Z]/.test(password)) return 'Password must contain at least one letter';
|
||||||
|
if (!/[^a-zA-Z]/.test(password)) return 'Password must contain at least one non-letter character';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function LockScreen() {
|
export default function LockScreen() {
|
||||||
const navigate = useNavigate();
|
const { authStatus, isLoading, login, setup, verifyTotp, mfaRequired, isLoginPending, isSetupPending, isTotpPending } = useAuth();
|
||||||
const { authStatus, login, setup, isLoginPending, isSetupPending } = useAuth();
|
|
||||||
const [pin, setPin] = useState('');
|
|
||||||
const [confirmPin, setConfirmPin] = useState('');
|
|
||||||
|
|
||||||
// Redirect authenticated users to dashboard
|
// Credentials state (shared across login/setup states)
|
||||||
if (authStatus?.authenticated) {
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
|
||||||
|
// TOTP challenge state
|
||||||
|
const [totpCode, setTotpCode] = useState('');
|
||||||
|
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||||
|
|
||||||
|
// Lockout handling (HTTP 423)
|
||||||
|
const [lockoutMessage, setLockoutMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Redirect authenticated users immediately
|
||||||
|
if (!isLoading && authStatus?.authenticated) {
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const isSetup = authStatus?.setup_required === true;
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (authStatus?.setup_required) {
|
const handleCredentialSubmit = async (e: FormEvent) => {
|
||||||
if (pin !== confirmPin) {
|
e.preventDefault();
|
||||||
toast.error('PINs do not match');
|
setLockoutMessage(null);
|
||||||
|
|
||||||
|
if (isSetup) {
|
||||||
|
// Setup mode: validate password then create account
|
||||||
|
const validationError = validatePassword(password);
|
||||||
|
if (validationError) {
|
||||||
|
toast.error(validationError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pin.length < 4) {
|
if (password !== confirmPassword) {
|
||||||
toast.error('PIN must be at least 4 characters');
|
toast.error('Passwords do not match');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await setup(pin);
|
await setup({ username, password });
|
||||||
toast.success('PIN created successfully');
|
// useAuth invalidates auth query → Navigate above handles redirect
|
||||||
navigate('/dashboard');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(getErrorMessage(error, 'Failed to create PIN'));
|
toast.error(getErrorMessage(error, 'Failed to create account'));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Login mode
|
||||||
try {
|
try {
|
||||||
await login(pin);
|
await login({ username, password });
|
||||||
navigate('/dashboard');
|
// If mfaRequired becomes true, the TOTP state renders automatically
|
||||||
} catch (error) {
|
// If not required, useAuth invalidates auth query → Navigate above handles redirect
|
||||||
toast.error(getErrorMessage(error, 'Invalid PIN'));
|
} catch (error: any) {
|
||||||
setPin('');
|
if (error?.response?.status === 423) {
|
||||||
|
const msg = error.response.data?.detail || 'Account locked. Try again later.';
|
||||||
|
setLockoutMessage(msg);
|
||||||
|
} else {
|
||||||
|
toast.error(getErrorMessage(error, 'Invalid username or password'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSetup = authStatus?.setup_required;
|
const handleTotpSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await verifyTotp(totpCode);
|
||||||
|
// useAuth invalidates auth query → Navigate above handles redirect
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, 'Invalid verification code'));
|
||||||
|
setTotpCode('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
<div className="relative flex min-h-screen flex-col items-center justify-center bg-background p-4 overflow-hidden">
|
||||||
<Card className="w-full max-w-md">
|
{/* Ambient glow blobs */}
|
||||||
<CardHeader className="space-y-4 text-center">
|
<div className="pointer-events-none absolute inset-0" aria-hidden="true">
|
||||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-accent/10">
|
<div
|
||||||
<Lock className="h-8 w-8 text-accent" />
|
className="absolute -top-32 -left-32 h-96 w-96 rounded-full opacity-20 blur-3xl"
|
||||||
</div>
|
style={{ background: 'radial-gradient(circle, hsl(var(--accent-color)) 0%, transparent 70%)' }}
|
||||||
<CardTitle className="text-2xl">
|
/>
|
||||||
{isSetup ? 'Welcome to UMBRA' : 'Enter PIN'}
|
<div
|
||||||
</CardTitle>
|
className="absolute -bottom-32 -right-32 h-96 w-96 rounded-full opacity-10 blur-3xl"
|
||||||
<CardDescription>
|
style={{ background: 'radial-gradient(circle, hsl(var(--accent-color)) 0%, transparent 70%)' }}
|
||||||
{isSetup
|
/>
|
||||||
? 'Create a PIN to secure your account'
|
</div>
|
||||||
: 'Enter your PIN to access your dashboard'}
|
|
||||||
</CardDescription>
|
{/* Wordmark — in flex flow above card */}
|
||||||
</CardHeader>
|
<span className="font-heading text-2xl font-bold tracking-tight text-accent mb-6 relative z-10">
|
||||||
<CardContent>
|
UMBRA
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
</span>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="pin">{isSetup ? 'Create PIN' : 'PIN'}</Label>
|
{/* Auth card */}
|
||||||
<Input
|
<Card className="w-full max-w-sm relative z-10 border-border/80 animate-slide-up">
|
||||||
id="pin"
|
{mfaRequired ? (
|
||||||
type="password"
|
// State C: TOTP challenge
|
||||||
value={pin}
|
<>
|
||||||
onChange={(e) => setPin(e.target.value)}
|
<CardHeader>
|
||||||
placeholder="Enter PIN"
|
<div className="flex items-center gap-3">
|
||||||
required
|
<div className="p-1.5 rounded-md bg-accent/10">
|
||||||
autoFocus
|
<Lock className="h-4 w-4 text-accent" aria-hidden="true" />
|
||||||
className="text-center text-lg tracking-widest"
|
</div>
|
||||||
/>
|
<div>
|
||||||
</div>
|
<CardTitle>Two-Factor Authentication</CardTitle>
|
||||||
{isSetup && (
|
<CardDescription>
|
||||||
<div className="space-y-2">
|
{useBackupCode
|
||||||
<Label htmlFor="confirm-pin">Confirm PIN</Label>
|
? 'Enter one of your backup codes'
|
||||||
<Input
|
: 'Enter the code from your authenticator app'}
|
||||||
id="confirm-pin"
|
</CardDescription>
|
||||||
type="password"
|
</div>
|
||||||
value={confirmPin}
|
|
||||||
onChange={(e) => setConfirmPin(e.target.value)}
|
|
||||||
placeholder="Confirm PIN"
|
|
||||||
required
|
|
||||||
className="text-center text-lg tracking-widest"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardHeader>
|
||||||
<Button
|
<CardContent>
|
||||||
type="submit"
|
<form onSubmit={handleTotpSubmit} className="space-y-4">
|
||||||
className="w-full"
|
<div className="space-y-2">
|
||||||
disabled={isLoginPending || isSetupPending}
|
<Label htmlFor="totp-code">
|
||||||
>
|
{useBackupCode ? 'Backup Code' : 'Authenticator Code'}
|
||||||
{isLoginPending || isSetupPending
|
</Label>
|
||||||
? 'Please wait...'
|
<Input
|
||||||
: isSetup
|
id="totp-code"
|
||||||
? 'Create PIN'
|
type="text"
|
||||||
: 'Unlock'}
|
inputMode={useBackupCode ? 'text' : 'numeric'}
|
||||||
</Button>
|
pattern={useBackupCode ? undefined : '[0-9]*'}
|
||||||
</form>
|
maxLength={useBackupCode ? 9 : 6}
|
||||||
</CardContent>
|
value={totpCode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTotpCode(
|
||||||
|
useBackupCode
|
||||||
|
? e.target.value.replace(/[^0-9-]/g, '')
|
||||||
|
: e.target.value.replace(/\D/g, '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={useBackupCode ? 'XXXX-XXXX' : '000000'}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
className="text-center text-lg tracking-widest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={isTotpPending}>
|
||||||
|
{isTotpPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Verifying
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Verify'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setUseBackupCode(!useBackupCode);
|
||||||
|
setTotpCode('');
|
||||||
|
}}
|
||||||
|
className="w-full text-center text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{useBackupCode ? 'Use authenticator app instead' : 'Use a backup code instead'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// State A (setup) or State B (login)
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-1.5 rounded-md bg-accent/10">
|
||||||
|
<Lock className="h-4 w-4 text-accent" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>{isSetup ? 'Welcome to UMBRA' : 'Sign in'}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{isSetup
|
||||||
|
? 'Create your account to get started'
|
||||||
|
: 'Enter your credentials to continue'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Lockout warning banner */}
|
||||||
|
{lockoutMessage && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-md border border-red-500/30',
|
||||||
|
'bg-red-500/10 px-3 py-2 mb-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Lock className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />
|
||||||
|
<p className="text-xs text-red-400">{lockoutMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleCredentialSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Enter username"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={isSetup ? 'Create a password' : 'Enter password'}
|
||||||
|
required
|
||||||
|
autoComplete={isSetup ? 'new-password' : 'current-password'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSetup && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm-password">Confirm Password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Must be 12-128 characters with at least one letter and one non-letter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoginPending || isSetupPending || !!lockoutMessage}
|
||||||
|
>
|
||||||
|
{isLoginPending || isSetupPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Please wait
|
||||||
|
</>
|
||||||
|
) : isSetup ? (
|
||||||
|
'Create Account'
|
||||||
|
) : (
|
||||||
|
'Sign in'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,13 +1,23 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, FormEvent, CSSProperties } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { MapPin, X, Search, Loader2 } from 'lucide-react';
|
import {
|
||||||
|
Settings,
|
||||||
|
User,
|
||||||
|
Palette,
|
||||||
|
Cloud,
|
||||||
|
CalendarDays,
|
||||||
|
LayoutDashboard,
|
||||||
|
MapPin,
|
||||||
|
X,
|
||||||
|
Search,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { GeoLocation } from '@/types';
|
import type { GeoLocation } from '@/types';
|
||||||
@ -22,7 +32,8 @@ const accentColors = [
|
|||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { settings, updateSettings, changePin, isUpdating, isChangingPin } = useSettings();
|
const { settings, updateSettings, isUpdating } = useSettings();
|
||||||
|
|
||||||
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan');
|
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan');
|
||||||
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7);
|
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7);
|
||||||
const [preferredName, setPreferredName] = useState(settings?.preferred_name ?? '');
|
const [preferredName, setPreferredName] = useState(settings?.preferred_name ?? '');
|
||||||
@ -34,11 +45,15 @@ export default function SettingsPage() {
|
|||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
const [firstDayOfWeek, setFirstDayOfWeek] = useState(settings?.first_day_of_week ?? 0);
|
const [firstDayOfWeek, setFirstDayOfWeek] = useState(settings?.first_day_of_week ?? 0);
|
||||||
|
|
||||||
const [pinForm, setPinForm] = useState({
|
// Sync state when settings load
|
||||||
oldPin: '',
|
useEffect(() => {
|
||||||
newPin: '',
|
if (settings) {
|
||||||
confirmPin: '',
|
setSelectedColor(settings.accent_color);
|
||||||
});
|
setUpcomingDays(settings.upcoming_days);
|
||||||
|
setPreferredName(settings.preferred_name ?? '');
|
||||||
|
setFirstDayOfWeek(settings.first_day_of_week);
|
||||||
|
}
|
||||||
|
}, [settings?.id]); // only re-sync on initial load (settings.id won't change)
|
||||||
|
|
||||||
const hasLocation = settings?.weather_lat != null && settings?.weather_lon != null;
|
const hasLocation = settings?.weather_lat != null && settings?.weather_lon != null;
|
||||||
|
|
||||||
@ -87,11 +102,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const handleLocationClear = async () => {
|
const handleLocationClear = async () => {
|
||||||
try {
|
try {
|
||||||
await updateSettings({
|
await updateSettings({ weather_city: null, weather_lat: null, weather_lon: null });
|
||||||
weather_city: null,
|
|
||||||
weather_lat: null,
|
|
||||||
weather_lon: null,
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['weather'] });
|
queryClient.invalidateQueries({ queryKey: ['weather'] });
|
||||||
toast.success('Weather location cleared');
|
toast.success('Weather location cleared');
|
||||||
} catch {
|
} catch {
|
||||||
@ -110,7 +121,6 @@ export default function SettingsPage() {
|
|||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Clean up debounce timer on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
@ -123,7 +133,7 @@ export default function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
await updateSettings({ preferred_name: trimmed || null });
|
await updateSettings({ preferred_name: trimmed || null });
|
||||||
toast.success('Name updated');
|
toast.success('Name updated');
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to update name');
|
toast.error('Failed to update name');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -133,7 +143,7 @@ export default function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
await updateSettings({ accent_color: color });
|
await updateSettings({ accent_color: color });
|
||||||
toast.success('Accent color updated');
|
toast.success('Accent color updated');
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to update accent color');
|
toast.error('Failed to update accent color');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -151,305 +161,291 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpcomingDaysSubmit = async (e: FormEvent) => {
|
const handleUpcomingDaysSave = async () => {
|
||||||
e.preventDefault();
|
if (isNaN(upcomingDays) || upcomingDays < 1 || upcomingDays > 30) return;
|
||||||
|
if (upcomingDays === settings?.upcoming_days) return;
|
||||||
try {
|
try {
|
||||||
await updateSettings({ upcoming_days: upcomingDays });
|
await updateSettings({ upcoming_days: upcomingDays });
|
||||||
toast.success('Settings updated');
|
toast.success('Settings updated');
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to update settings');
|
toast.error('Failed to update settings');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePinSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (pinForm.newPin !== pinForm.confirmPin) {
|
|
||||||
toast.error('New PINs do not match');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pinForm.newPin.length < 4) {
|
|
||||||
toast.error('PIN must be at least 4 characters');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await changePin({ oldPin: pinForm.oldPin, newPin: pinForm.newPin });
|
|
||||||
toast.success('PIN changed successfully');
|
|
||||||
setPinForm({ oldPin: '', newPin: '', confirmPin: '' });
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.response?.data?.detail || 'Failed to change PIN');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="border-b bg-card px-6 py-4">
|
{/* Page header — matches Stage 4-5 pages */}
|
||||||
<h1 className="text-3xl font-bold">Settings</h1>
|
<div className="border-b bg-card px-6 h-16 flex items-center gap-3 shrink-0">
|
||||||
|
<Settings className="h-5 w-5 text-accent" aria-hidden="true" />
|
||||||
|
<h1 className="text-xl font-semibold font-heading">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="max-w-5xl mx-auto">
|
||||||
<Card>
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Profile</CardTitle>
|
|
||||||
<CardDescription>Personalize how UMBRA greets you</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="preferred_name">Preferred Name</Label>
|
|
||||||
<div className="flex gap-3 items-center">
|
|
||||||
<Input
|
|
||||||
id="preferred_name"
|
|
||||||
type="text"
|
|
||||||
placeholder="Enter your name"
|
|
||||||
value={preferredName}
|
|
||||||
onChange={(e) => setPreferredName(e.target.value)}
|
|
||||||
onBlur={handleNameSave}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleNameSave(); }}
|
|
||||||
className="max-w-xs"
|
|
||||||
maxLength={100}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}."
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
{/* ── Left column: Profile, Appearance, Weather ── */}
|
||||||
<CardHeader>
|
<div className="space-y-6">
|
||||||
<CardTitle>Appearance</CardTitle>
|
|
||||||
<CardDescription>Customize the look and feel of your application</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label>Accent Color</Label>
|
|
||||||
<div className="flex gap-3 mt-3">
|
|
||||||
{accentColors.map((color) => (
|
|
||||||
<button
|
|
||||||
key={color.name}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleColorChange(color.name)}
|
|
||||||
className={cn(
|
|
||||||
'h-12 w-12 rounded-full border-2 transition-all hover:scale-110',
|
|
||||||
selectedColor === color.name
|
|
||||||
? 'border-white ring-2 ring-offset-2 ring-offset-background'
|
|
||||||
: 'border-transparent'
|
|
||||||
)}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
backgroundColor: color.color,
|
|
||||||
'--tw-ring-color': color.color,
|
|
||||||
} as CSSProperties
|
|
||||||
}
|
|
||||||
title={color.label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
{/* Profile */}
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>Calendar</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>Configure your calendar preferences</CardDescription>
|
<div className="flex items-center gap-3">
|
||||||
</CardHeader>
|
<div className="p-1.5 rounded-md bg-accent/10">
|
||||||
<CardContent>
|
<User className="h-4 w-4 text-accent" aria-hidden="true" />
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>First Day of Week</Label>
|
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden w-fit">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleFirstDayChange(0)}
|
|
||||||
className={cn(
|
|
||||||
'px-4 py-2 text-sm font-medium transition-colors duration-150',
|
|
||||||
firstDayOfWeek === 0
|
|
||||||
? 'text-accent'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
backgroundColor: firstDayOfWeek === 0 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
|
||||||
color: firstDayOfWeek === 0 ? 'hsl(var(--accent-color))' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Sunday
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleFirstDayChange(1)}
|
|
||||||
className={cn(
|
|
||||||
'px-4 py-2 text-sm font-medium transition-colors duration-150',
|
|
||||||
firstDayOfWeek === 1
|
|
||||||
? 'text-accent'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
backgroundColor: firstDayOfWeek === 1 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
|
||||||
color: firstDayOfWeek === 1 ? 'hsl(var(--accent-color))' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Monday
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Sets which day the calendar week starts on
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Dashboard</CardTitle>
|
|
||||||
<CardDescription>Configure your dashboard preferences</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleUpcomingDaysSubmit} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="upcoming_days">Upcoming Days Range</Label>
|
|
||||||
<div className="flex gap-3 items-center">
|
|
||||||
<Input
|
|
||||||
id="upcoming_days"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="30"
|
|
||||||
value={upcomingDays}
|
|
||||||
onChange={(e) => setUpcomingDays(parseInt(e.target.value))}
|
|
||||||
className="w-24"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground">days</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
How many days ahead to show in the upcoming items widget
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" disabled={isUpdating}>
|
|
||||||
{isUpdating ? 'Saving...' : 'Save'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Weather</CardTitle>
|
|
||||||
<CardDescription>Configure the weather widget on your dashboard</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Location</Label>
|
|
||||||
{hasLocation ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="inline-flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-1.5 text-sm text-foreground">
|
|
||||||
<MapPin className="h-3.5 w-3.5 text-accent" />
|
|
||||||
{settings?.weather_city || `${settings?.weather_lat}, ${settings?.weather_lon}`}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleLocationClear}
|
|
||||||
className="inline-flex items-center justify-center rounded-md h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
||||||
title="Clear location"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div ref={searchRef} className="relative max-w-sm">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search for a city..."
|
|
||||||
value={locationQuery}
|
|
||||||
onChange={(e) => handleLocationInputChange(e.target.value)}
|
|
||||||
onFocus={() => { if (locationResults.length > 0) setShowDropdown(true); }}
|
|
||||||
className="pl-9 pr-9"
|
|
||||||
/>
|
|
||||||
{isSearching && (
|
|
||||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground animate-spin" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{showDropdown && (
|
<div>
|
||||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden">
|
<CardTitle>Profile</CardTitle>
|
||||||
{locationResults.map((loc, i) => {
|
<CardDescription>Personalize how UMBRA greets you</CardDescription>
|
||||||
return (
|
</div>
|
||||||
<button
|
</div>
|
||||||
key={`${loc.lat}-${loc.lon}-${i}`}
|
</CardHeader>
|
||||||
type="button"
|
<CardContent>
|
||||||
onClick={() => handleLocationSelect(loc)}
|
<div className="space-y-2">
|
||||||
className="flex items-center gap-2.5 w-full px-3 py-2.5 text-sm text-left hover:bg-accent/10 transition-colors"
|
<Label htmlFor="preferred_name">Preferred Name</Label>
|
||||||
>
|
<Input
|
||||||
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
id="preferred_name"
|
||||||
<span>
|
type="text"
|
||||||
<span className="text-foreground font-medium">{loc.name}</span>
|
placeholder="Enter your name"
|
||||||
{(loc.state || loc.country) && (
|
value={preferredName}
|
||||||
<span className="text-muted-foreground">
|
onChange={(e) => setPreferredName(e.target.value)}
|
||||||
{loc.state ? `, ${loc.state}` : ''}{loc.country ? `, ${loc.country}` : ''}
|
onBlur={handleNameSave}
|
||||||
</span>
|
onKeyDown={(e) => { if (e.key === 'Enter') handleNameSave(); }}
|
||||||
)}
|
maxLength={100}
|
||||||
</span>
|
/>
|
||||||
</button>
|
<p className="text-sm text-muted-foreground">
|
||||||
);
|
Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}."
|
||||||
})}
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Appearance */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-1.5 rounded-md bg-purple-500/10">
|
||||||
|
<Palette className="h-4 w-4 text-purple-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Appearance</CardTitle>
|
||||||
|
<CardDescription>Customize the look and feel of your application</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div>
|
||||||
|
<Label>Accent Color</Label>
|
||||||
|
<div className="grid grid-cols-5 gap-3 mt-3">
|
||||||
|
{accentColors.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleColorChange(color.name)}
|
||||||
|
aria-pressed={selectedColor === color.name}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center gap-2 p-3 rounded-lg border transition-all duration-150',
|
||||||
|
selectedColor === color.name
|
||||||
|
? 'border-accent/50 bg-accent/5'
|
||||||
|
: 'border-border hover:border-border/80 hover:bg-card-elevated'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
style={{ backgroundColor: color.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||||
|
{color.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Weather */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-1.5 rounded-md bg-amber-500/10">
|
||||||
|
<Cloud className="h-4 w-4 text-amber-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Weather</CardTitle>
|
||||||
|
<CardDescription>Configure the weather widget on your dashboard</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Location</Label>
|
||||||
|
{hasLocation ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-1.5 text-sm text-foreground">
|
||||||
|
<MapPin className="h-3.5 w-3.5 text-accent" />
|
||||||
|
{settings?.weather_city || `${settings?.weather_lat}, ${settings?.weather_lon}`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLocationClear}
|
||||||
|
className="inline-flex items-center justify-center rounded-md h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
title="Clear location"
|
||||||
|
aria-label="Clear weather location"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div ref={searchRef} className="relative">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search for a city..."
|
||||||
|
value={locationQuery}
|
||||||
|
onChange={(e) => handleLocationInputChange(e.target.value)}
|
||||||
|
onFocus={() => { if (locationResults.length > 0) setShowDropdown(true); }}
|
||||||
|
className="pl-9 pr-9"
|
||||||
|
/>
|
||||||
|
{isSearching && (
|
||||||
|
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showDropdown && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden">
|
||||||
|
{locationResults.map((loc, i) => (
|
||||||
|
<button
|
||||||
|
key={`${loc.lat}-${loc.lon}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleLocationSelect(loc)}
|
||||||
|
className="flex items-center gap-2.5 w-full px-3 py-2.5 text-sm text-left hover:bg-accent/10 transition-colors"
|
||||||
|
>
|
||||||
|
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<span>
|
||||||
|
<span className="text-foreground font-medium">{loc.name}</span>
|
||||||
|
{(loc.state || loc.country) && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{loc.state ? `, ${loc.state}` : ''}{loc.country ? `, ${loc.country}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Search and select your city for accurate weather data on the dashboard.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardContent>
|
||||||
<p className="text-sm text-muted-foreground">
|
</Card>
|
||||||
Search and select your city for accurate weather data on the dashboard.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
</div>
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Security</CardTitle>
|
{/* ── Right column: Calendar, Dashboard ── */}
|
||||||
<CardDescription>Change your PIN</CardDescription>
|
<div className="space-y-6">
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
{/* Calendar */}
|
||||||
<form onSubmit={handlePinSubmit} className="space-y-4">
|
<Card>
|
||||||
<div className="space-y-2">
|
<CardHeader>
|
||||||
<Label htmlFor="old_pin">Current PIN</Label>
|
<div className="flex items-center gap-3">
|
||||||
<Input
|
<div className="p-1.5 rounded-md bg-blue-500/10">
|
||||||
id="old_pin"
|
<CalendarDays className="h-4 w-4 text-blue-400" aria-hidden="true" />
|
||||||
type="password"
|
</div>
|
||||||
value={pinForm.oldPin}
|
<div>
|
||||||
onChange={(e) => setPinForm({ ...pinForm, oldPin: e.target.value })}
|
<CardTitle>Calendar</CardTitle>
|
||||||
required
|
<CardDescription>Configure your calendar preferences</CardDescription>
|
||||||
className="max-w-xs"
|
</div>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
<Separator />
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="new_pin">New PIN</Label>
|
<Label>First Day of Week</Label>
|
||||||
<Input
|
<div className="flex items-center rounded-md border border-border overflow-hidden w-fit">
|
||||||
id="new_pin"
|
<button
|
||||||
type="password"
|
type="button"
|
||||||
value={pinForm.newPin}
|
onClick={() => handleFirstDayChange(0)}
|
||||||
onChange={(e) => setPinForm({ ...pinForm, newPin: e.target.value })}
|
className={cn(
|
||||||
required
|
'px-4 py-2 text-sm font-medium transition-colors duration-150',
|
||||||
className="max-w-xs"
|
firstDayOfWeek === 0
|
||||||
/>
|
? 'text-accent'
|
||||||
</div>
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||||
<div className="space-y-2">
|
)}
|
||||||
<Label htmlFor="confirm_pin">Confirm New PIN</Label>
|
style={{
|
||||||
<Input
|
backgroundColor: firstDayOfWeek === 0 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||||
id="confirm_pin"
|
color: firstDayOfWeek === 0 ? 'hsl(var(--accent-color))' : undefined,
|
||||||
type="password"
|
}}
|
||||||
value={pinForm.confirmPin}
|
>
|
||||||
onChange={(e) => setPinForm({ ...pinForm, confirmPin: e.target.value })}
|
Sunday
|
||||||
required
|
</button>
|
||||||
className="max-w-xs"
|
<button
|
||||||
/>
|
type="button"
|
||||||
</div>
|
onClick={() => handleFirstDayChange(1)}
|
||||||
<Button type="submit" disabled={isChangingPin}>
|
className={cn(
|
||||||
{isChangingPin ? 'Changing...' : 'Change PIN'}
|
'px-4 py-2 text-sm font-medium transition-colors duration-150',
|
||||||
</Button>
|
firstDayOfWeek === 1
|
||||||
</form>
|
? 'text-accent'
|
||||||
</CardContent>
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||||
</Card>
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: firstDayOfWeek === 1 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||||
|
color: firstDayOfWeek === 1 ? 'hsl(var(--accent-color))' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Monday
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Sets which day the calendar week starts on
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Dashboard */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-1.5 rounded-md bg-teal-500/10">
|
||||||
|
<LayoutDashboard className="h-4 w-4 text-teal-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Dashboard</CardTitle>
|
||||||
|
<CardDescription>Configure your dashboard preferences</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="upcoming_days">Upcoming Days Range</Label>
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<Input
|
||||||
|
id="upcoming_days"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="30"
|
||||||
|
value={upcomingDays}
|
||||||
|
onChange={(e) => setUpcomingDays(parseInt(e.target.value))}
|
||||||
|
onBlur={handleUpcomingDaysSave}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleUpcomingDaysSave(); }}
|
||||||
|
className="w-24"
|
||||||
|
disabled={isUpdating}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">days</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
How many days ahead to show in the upcoming items widget
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -23,23 +23,10 @@ export function useSettings() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// @deprecated — PIN auth is replaced by username/password in Stage 6.
|
|
||||||
// SettingsPage will be rewritten in Phase 3 to remove this. Kept here to
|
|
||||||
// preserve compilation until SettingsPage.tsx is updated.
|
|
||||||
const changePinMutation = useMutation({
|
|
||||||
mutationFn: async ({ oldPin, newPin }: { oldPin: string; newPin: string }) => {
|
|
||||||
const { data } = await api.put('/settings/pin', { old_pin: oldPin, new_pin: newPin });
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
settings: settingsQuery.data,
|
settings: settingsQuery.data,
|
||||||
isLoading: settingsQuery.isLoading,
|
isLoading: settingsQuery.isLoading,
|
||||||
updateSettings: updateMutation.mutateAsync,
|
updateSettings: updateMutation.mutateAsync,
|
||||||
isUpdating: updateMutation.isPending,
|
isUpdating: updateMutation.isPending,
|
||||||
// @deprecated — remove when SettingsPage is rewritten in Phase 3
|
|
||||||
changePin: changePinMutation.mutateAsync,
|
|
||||||
isChangingPin: changePinMutation.isPending,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user