UMBRA/frontend/src/components/settings/SettingsPage.tsx
Kyle Pope 5a8819c4a5 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>
2026-02-25 04:06:53 +08:00

454 lines
19 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
import {
Settings,
User,
Palette,
Cloud,
CalendarDays,
LayoutDashboard,
MapPin,
X,
Search,
Loader2,
} from 'lucide-react';
import { useSettings } from '@/hooks/useSettings';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import api from '@/lib/api';
import type { GeoLocation } from '@/types';
const accentColors = [
{ name: 'cyan', label: 'Cyan', color: '#06b6d4' },
{ name: 'blue', label: 'Blue', color: '#3b82f6' },
{ name: 'purple', label: 'Purple', color: '#8b5cf6' },
{ name: 'orange', label: 'Orange', color: '#f97316' },
{ name: 'green', label: 'Green', color: '#22c55e' },
];
export default function SettingsPage() {
const queryClient = useQueryClient();
const { settings, updateSettings, isUpdating } = useSettings();
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan');
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7);
const [preferredName, setPreferredName] = useState(settings?.preferred_name ?? '');
const [locationQuery, setLocationQuery] = useState('');
const [locationResults, setLocationResults] = useState<GeoLocation[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const [firstDayOfWeek, setFirstDayOfWeek] = useState(settings?.first_day_of_week ?? 0);
// Sync state when settings load
useEffect(() => {
if (settings) {
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 searchLocations = useCallback(async (query: string) => {
if (query.length < 2) {
setLocationResults([]);
setShowDropdown(false);
return;
}
setIsSearching(true);
try {
const { data } = await api.get<GeoLocation[]>('/weather/search', { params: { q: query } });
setLocationResults(data);
setShowDropdown(data.length > 0);
} catch {
setLocationResults([]);
setShowDropdown(false);
} finally {
setIsSearching(false);
}
}, []);
const handleLocationInputChange = (value: string) => {
setLocationQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => searchLocations(value), 300);
};
const handleLocationSelect = async (loc: GeoLocation) => {
const displayName = [loc.name, loc.state, loc.country].filter(Boolean).join(', ');
setShowDropdown(false);
setLocationQuery('');
setLocationResults([]);
try {
await updateSettings({
weather_city: displayName,
weather_lat: loc.lat,
weather_lon: loc.lon,
});
queryClient.invalidateQueries({ queryKey: ['weather'] });
toast.success(`Weather location set to ${displayName}`);
} catch {
toast.error('Failed to update weather location');
}
};
const handleLocationClear = async () => {
try {
await updateSettings({ weather_city: null, weather_lat: null, weather_lon: null });
queryClient.invalidateQueries({ queryKey: ['weather'] });
toast.success('Weather location cleared');
} catch {
toast.error('Failed to clear weather location');
}
};
// Close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
const handleNameSave = async () => {
const trimmed = preferredName.trim();
if (trimmed === (settings?.preferred_name || '')) return;
try {
await updateSettings({ preferred_name: trimmed || null });
toast.success('Name updated');
} catch {
toast.error('Failed to update name');
}
};
const handleColorChange = async (color: string) => {
setSelectedColor(color);
try {
await updateSettings({ accent_color: color });
toast.success('Accent color updated');
} catch {
toast.error('Failed to update accent color');
}
};
const handleFirstDayChange = async (value: number) => {
const previous = firstDayOfWeek;
setFirstDayOfWeek(value);
try {
await updateSettings({ first_day_of_week: value });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
toast.success(value === 0 ? 'Week starts on Sunday' : 'Week starts on Monday');
} catch {
setFirstDayOfWeek(previous);
toast.error('Failed to update first day of week');
}
};
const handleUpcomingDaysSave = async () => {
if (isNaN(upcomingDays) || upcomingDays < 1 || upcomingDays > 30) return;
if (upcomingDays === settings?.upcoming_days) return;
try {
await updateSettings({ upcoming_days: upcomingDays });
toast.success('Settings updated');
} catch {
toast.error('Failed to update settings');
}
};
return (
<div className="flex flex-col h-full">
{/* Page header — matches Stage 4-5 pages */}
<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 className="flex-1 overflow-y-auto p-6">
<div className="max-w-5xl mx-auto">
<div className="grid gap-6 lg:grid-cols-2">
{/* ── Left column: Profile, Appearance, Weather ── */}
<div className="space-y-6">
{/* Profile */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-accent/10">
<User className="h-4 w-4 text-accent" aria-hidden="true" />
</div>
<div>
<CardTitle>Profile</CardTitle>
<CardDescription>Personalize how UMBRA greets you</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="preferred_name">Preferred Name</Label>
<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(); }}
maxLength={100}
/>
<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>
)}
<p className="text-sm text-muted-foreground">
Search and select your city for accurate weather data on the dashboard.
</p>
</div>
</CardContent>
</Card>
</div>
{/* ── Right column: Calendar, Dashboard ── */}
<div className="space-y-6">
{/* Calendar */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-blue-500/10">
<CalendarDays className="h-4 w-4 text-blue-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Calendar</CardTitle>
<CardDescription>Configure your calendar preferences</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<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>
{/* 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>
);
}