diff --git a/frontend/src/components/admin/AdminPortal.tsx b/frontend/src/components/admin/AdminPortal.tsx index 2f8e5ad..195fd71 100644 --- a/frontend/src/components/admin/AdminPortal.tsx +++ b/frontend/src/components/admin/AdminPortal.tsx @@ -19,15 +19,18 @@ export default function AdminPortal() { {/* Portal header with tab navigation */}
-
+
-

Admin

+

+ Admin Portal + Admin +

- {/* Horizontal tab navigation */} -
); diff --git a/frontend/src/components/locations/LocationsPage.tsx b/frontend/src/components/locations/LocationsPage.tsx index e0f643c..3174292 100644 --- a/frontend/src/components/locations/LocationsPage.tsx +++ b/frontend/src/components/locations/LocationsPage.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useRef, useEffect } from 'react'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery'; import { Plus, MapPin, Phone, Mail, Tag, AlignLeft } from 'lucide-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -17,10 +17,11 @@ import { import { useTableVisibility } from '@/hooks/useTableVisibility'; import { useCategoryOrder } from '@/hooks/useCategoryOrder'; import LocationForm from './LocationForm'; +import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay'; export default function LocationsPage() { const queryClient = useQueryClient(); - const isDesktop = useMediaQuery('(min-width: 1024px)'); + const isDesktop = useMediaQuery(DESKTOP); const [selectedLocationId, setSelectedLocationId] = useState(null); const [showForm, setShowForm] = useState(false); @@ -387,17 +388,9 @@ export default function LocationsPage() { {/* Mobile detail panel overlay */} {panelOpen && selectedLocation && !isDesktop && ( -
setSelectedLocationId(null)} - > -
e.stopPropagation()} - > - {renderPanel()} -
-
+ setSelectedLocationId(null)}> + {renderPanel()} + )} {showForm && ( diff --git a/frontend/src/components/people/PeoplePage.tsx b/frontend/src/components/people/PeoplePage.tsx index 59328d4..9ab6e46 100644 --- a/frontend/src/components/people/PeoplePage.tsx +++ b/frontend/src/components/people/PeoplePage.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useRef, useEffect } from 'react'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery'; import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink, Link2, User2 } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -27,6 +27,7 @@ import PersonForm from './PersonForm'; import ConnectionSearch from '@/components/connections/ConnectionSearch'; import ConnectionRequestCard from '@/components/connections/ConnectionRequestCard'; import { useConnections } from '@/hooks/useConnections'; +import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay'; // --------------------------------------------------------------------------- // StatCounter — inline helper @@ -215,7 +216,7 @@ const panelFields: PanelField[] = [ export default function PeoplePage() { const queryClient = useQueryClient(); const tableContainerRef = useRef(null); - const isDesktop = useMediaQuery('(min-width: 1024px)'); + const isDesktop = useMediaQuery(DESKTOP); const [selectedPersonId, setSelectedPersonId] = useState(null); const [showForm, setShowForm] = useState(false); @@ -776,17 +777,9 @@ export default function PeoplePage() { {/* Mobile detail panel overlay */} {panelOpen && selectedPerson && !isDesktop && ( -
setSelectedPersonId(null)} - > -
e.stopPropagation()} - > - {renderPanel()} -
-
+ setSelectedPersonId(null)}> + {renderPanel()} + )} {showForm && ( diff --git a/frontend/src/components/projects/KanbanBoard.tsx b/frontend/src/components/projects/KanbanBoard.tsx index a27a3df..04452da 100644 --- a/frontend/src/components/projects/KanbanBoard.tsx +++ b/frontend/src/components/projects/KanbanBoard.tsx @@ -1,7 +1,7 @@ import { DndContext, closestCorners, - PointerSensor, + PointerSensor, TouchSensor, useSensor, useSensors, @@ -153,8 +153,8 @@ export default function KanbanBoard({ onBackToAllTasks, }: KanbanBoardProps) { const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) , - useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }) + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) , + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }) ); // Subtask view is driven by kanbanParentTask (decoupled from selected task) diff --git a/frontend/src/components/projects/ProjectDetail.tsx b/frontend/src/components/projects/ProjectDetail.tsx index 867bd54..b85a841 100644 --- a/frontend/src/components/projects/ProjectDetail.tsx +++ b/frontend/src/components/projects/ProjectDetail.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useCallback } from 'react'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery'; import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -39,6 +39,7 @@ import KanbanBoard from './KanbanBoard'; import TaskForm from './TaskForm'; import ProjectForm from './ProjectForm'; import { statusColors, statusLabels } from './constants'; +import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay'; type SortMode = 'manual' | 'priority' | 'due_date'; type ViewMode = 'list' | 'kanban'; @@ -258,7 +259,7 @@ export default function ProjectDetail() { } }, [topLevelTasks, sortMode, sortSubtasks]); - const isDesktop = useMediaQuery('(min-width: 1024px)'); + const isDesktop = useMediaQuery(DESKTOP); const selectedTask = useMemo(() => { if (!selectedTaskId) return null; @@ -653,30 +654,28 @@ export default function ProjectDetail() { {/* Mobile: show detail panel as overlay when task selected on small screens */} {selectedTaskId && selectedTask && !isDesktop && ( -
-
-
- Task Details - -
-
- openTaskForm(null, parentId)} - onClose={() => setSelectedTaskId(null)} - onSelectTask={setSelectedTaskId} - /> -
+ setSelectedTaskId(null)}> +
+ Task Details +
-
+
+ openTaskForm(null, parentId)} + onClose={() => setSelectedTaskId(null)} + onSelectTask={setSelectedTaskId} + /> +
+ )} {showTaskForm && ( diff --git a/frontend/src/components/reminders/RemindersPage.tsx b/frontend/src/components/reminders/RemindersPage.tsx index 328f1ca..c3b4933 100644 --- a/frontend/src/components/reminders/RemindersPage.tsx +++ b/frontend/src/components/reminders/RemindersPage.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useEffect } from 'react'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery'; import { useLocation } from 'react-router-dom'; import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; @@ -13,6 +13,7 @@ import { Card, CardContent } from '@/components/ui/card'; import { ListSkeleton } from '@/components/ui/skeleton'; import ReminderList from './ReminderList'; import ReminderDetailPanel from './ReminderDetailPanel'; +import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay'; const statusFilters = [ { value: 'active', label: 'Active' }, @@ -25,7 +26,7 @@ type StatusFilter = (typeof statusFilters)[number]['value']; export default function RemindersPage() { const location = useLocation(); - const isDesktop = useMediaQuery('(min-width: 1024px)'); + const isDesktop = useMediaQuery(DESKTOP); // Panel state const [selectedReminderId, setSelectedReminderId] = useState(null); @@ -235,22 +236,14 @@ export default function RemindersPage() { {/* Mobile detail panel overlay */} {panelOpen && !isDesktop && ( -
-
e.stopPropagation()} - > - -
-
+ + + )}
); diff --git a/frontend/src/components/settings/AppearanceTab.tsx b/frontend/src/components/settings/AppearanceTab.tsx new file mode 100644 index 0000000..b417365 --- /dev/null +++ b/frontend/src/components/settings/AppearanceTab.tsx @@ -0,0 +1,180 @@ +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { useQueryClient } from '@tanstack/react-query'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import type { Settings } from '@/types'; + +const accentColors = [ + { name: 'cyan', color: '#06b6d4' }, + { name: 'blue', color: '#3b82f6' }, + { name: 'purple', color: '#8b5cf6' }, + { name: 'orange', color: '#f97316' }, + { name: 'green', color: '#22c55e' }, + { name: 'red', color: '#ef4444' }, + { name: 'pink', color: '#ec4899' }, + { name: 'yellow', color: '#eab308' }, +]; + +interface AppearanceTabProps { + settings: Settings | undefined; + updateSettings: (updates: Partial) => Promise; + isUpdating: boolean; +} + +export default function AppearanceTab({ settings, updateSettings, isUpdating }: AppearanceTabProps) { + const queryClient = useQueryClient(); + const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan'); + const [firstDayOfWeek, setFirstDayOfWeek] = useState(settings?.first_day_of_week ?? 0); + const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7); + + useEffect(() => { + if (settings) { + setSelectedColor(settings.accent_color); + setFirstDayOfWeek(settings.first_day_of_week); + setUpcomingDays(settings.upcoming_days); + } + }, [settings?.id]); + + 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 ( +
+ {/* Accent Color */} + + +
+ +
+ {accentColors.map((c) => ( + + ))} +
+
+
+
+ + {/* Calendar & Dashboard */} + + +
+ +
+ + +
+

+ Sets which day the calendar week starts on +

+
+ +
+ +
+ setUpcomingDays(parseInt(e.target.value))} + onBlur={handleUpcomingDaysSave} + onKeyDown={(e) => { if (e.key === 'Enter') handleUpcomingDaysSave(); }} + className="w-24" + disabled={isUpdating} + /> + days +
+

+ How many days ahead to show in the upcoming items widget +

+
+
+
+
+ ); +} diff --git a/frontend/src/components/settings/IntegrationsTab.tsx b/frontend/src/components/settings/IntegrationsTab.tsx new file mode 100644 index 0000000..833f9c7 --- /dev/null +++ b/frontend/src/components/settings/IntegrationsTab.tsx @@ -0,0 +1,182 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { toast } from 'sonner'; +import { useQueryClient } from '@tanstack/react-query'; +import { + MapPin, + X, + Search, + Loader2, +} from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent } from '@/components/ui/card'; +import api from '@/lib/api'; +import NtfySettingsSection from './NtfySettingsSection'; +import type { Settings, GeoLocation } from '@/types'; + +interface IntegrationsTabProps { + settings: Settings | undefined; + updateSettings: ( + updates: Partial & { preferred_name?: string | null; ntfy_auth_token?: string } + ) => Promise; +} + +export default function IntegrationsTab({ settings, updateSettings }: IntegrationsTabProps) { + const queryClient = useQueryClient(); + const [locationQuery, setLocationQuery] = useState(''); + const [locationResults, setLocationResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [showDropdown, setShowDropdown] = useState(false); + const searchRef = useRef(null); + const debounceRef = useRef>(); + + 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('/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'); + } + }; + + 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); + }; + }, []); + + return ( +
+ {/* Weather Location */} + + +
+ + {hasLocation ? ( +
+ + + {settings?.weather_city || `${settings?.weather_lat}, ${settings?.weather_lon}`} + + +
+ ) : ( +
+
+ + handleLocationInputChange(e.target.value)} + onFocus={() => { if (locationResults.length > 0) setShowDropdown(true); }} + className="pl-9 pr-9" + /> + {isSearching && ( + + )} +
+ {showDropdown && ( +
+ {locationResults.map((loc, i) => ( + + ))} +
+ )} +
+ )} +

+ Search and select your city for accurate weather data on the dashboard. +

+
+
+
+ + {/* ntfy Push Notifications */} + + + + + +
+ ); +} diff --git a/frontend/src/components/settings/ProfileTab.tsx b/frontend/src/components/settings/ProfileTab.tsx new file mode 100644 index 0000000..d7440a4 --- /dev/null +++ b/frontend/src/components/settings/ProfileTab.tsx @@ -0,0 +1,269 @@ +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { useQueryClient, useQuery } from '@tanstack/react-query'; +import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent } from '@/components/ui/card'; +import api from '@/lib/api'; +import type { Settings, UserProfile } from '@/types'; + +interface ProfileTabProps { + settings: Settings | undefined; + updateSettings: (updates: Partial & { preferred_name?: string | null }) => Promise; +} + +export default function ProfileTab({ settings, updateSettings }: ProfileTabProps) { + const queryClient = useQueryClient(); + + // Profile fields (stored on User model via /auth/profile) + const profileQuery = useQuery({ + queryKey: ['profile'], + queryFn: async () => { + const { data } = await api.get('/auth/profile'); + return data; + }, + }); + + const [preferredName, setPreferredName] = useState(settings?.preferred_name ?? ''); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [profileEmail, setProfileEmail] = useState(''); + const [dateOfBirth, setDateOfBirth] = useState(''); + const [emailError, setEmailError] = useState(null); + + // Profile extension fields (stored on Settings model) + const [settingsPhone, setSettingsPhone] = useState(settings?.phone ?? ''); + const [settingsMobile, setSettingsMobile] = useState(settings?.mobile ?? ''); + const [settingsAddress, setSettingsAddress] = useState(settings?.address ?? ''); + const [settingsCompany, setSettingsCompany] = useState(settings?.company ?? ''); + const [settingsJobTitle, setSettingsJobTitle] = useState(settings?.job_title ?? ''); + + useEffect(() => { + if (profileQuery.data) { + setFirstName(profileQuery.data.first_name ?? ''); + setLastName(profileQuery.data.last_name ?? ''); + setProfileEmail(profileQuery.data.email ?? ''); + setDateOfBirth(profileQuery.data.date_of_birth ?? ''); + } + }, [profileQuery.dataUpdatedAt]); + + useEffect(() => { + if (settings) { + setPreferredName(settings.preferred_name ?? ''); + setSettingsPhone(settings.phone ?? ''); + setSettingsMobile(settings.mobile ?? ''); + setSettingsAddress(settings.address ?? ''); + setSettingsCompany(settings.company ?? ''); + setSettingsJobTitle(settings.job_title ?? ''); + } + }, [settings?.id]); + + 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 handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth') => { + const values: Record = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth }; + const current = values[field].trim(); + const original = profileQuery.data?.[field] ?? ''; + if (current === (original || '')) return; + + if (field === 'email' && current) { + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(current)) { + setEmailError('Invalid email format'); + return; + } + } + setEmailError(null); + + try { + await api.put('/auth/profile', { [field]: current || null }); + queryClient.invalidateQueries({ queryKey: ['profile'] }); + toast.success('Profile updated'); + } catch (err: any) { + const detail = err?.response?.data?.detail; + if (field === 'email' && detail) { + setEmailError(typeof detail === 'string' ? detail : 'Failed to update email'); + } else { + toast.error(typeof detail === 'string' ? detail : 'Failed to update profile'); + } + } + }; + + const handleSettingsFieldSave = async (field: string, value: string) => { + const trimmed = value.trim(); + const currentVal = (settings as any)?.[field] || ''; + if (trimmed === (currentVal || '')) return; + try { + await updateSettings({ [field]: trimmed || null } as any); + toast.success('Profile updated'); + } catch { + toast.error('Failed to update profile'); + } + }; + + return ( + + +

+ Personal Information +

+
+ + setPreferredName(e.target.value)} + onBlur={handleNameSave} + onKeyDown={(e) => { if (e.key === 'Enter') handleNameSave(); }} + maxLength={100} + /> +

+ Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}." +

+
+
+
+ + setFirstName(e.target.value)} + onBlur={() => handleProfileSave('first_name')} + onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('first_name'); }} + maxLength={100} + /> +
+
+ + setLastName(e.target.value)} + onBlur={() => handleProfileSave('last_name')} + onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('last_name'); }} + maxLength={100} + /> +
+
+
+ + { setProfileEmail(e.target.value); setEmailError(null); }} + onBlur={() => handleProfileSave('email')} + onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('email'); }} + maxLength={254} + className={emailError ? 'border-red-500/50' : ''} + /> + {emailError && ( +

{emailError}

+ )} +
+
+ + { + setDateOfBirth(v); + const orig = profileQuery.data?.date_of_birth ?? ''; + if (v.trim() !== (orig || '')) { + api.put('/auth/profile', { date_of_birth: v.trim() || null }) + .then(() => { queryClient.invalidateQueries({ queryKey: ['profile'] }); toast.success('Profile updated'); }) + .catch(() => toast.error('Failed to update profile')); + } + }} + /> +
+
+
+ + setSettingsPhone(e.target.value)} + onBlur={() => handleSettingsFieldSave('phone', settingsPhone)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('phone', settingsPhone); }} + maxLength={50} + /> +
+
+ + setSettingsMobile(e.target.value)} + onBlur={() => handleSettingsFieldSave('mobile', settingsMobile)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('mobile', settingsMobile); }} + maxLength={50} + /> +
+
+
+ + setSettingsAddress(e.target.value)} + onBlur={() => handleSettingsFieldSave('address', settingsAddress)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('address', settingsAddress); }} + maxLength={2000} + /> +
+
+
+ + setSettingsCompany(e.target.value)} + onBlur={() => handleSettingsFieldSave('company', settingsCompany)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('company', settingsCompany); }} + maxLength={255} + /> +
+
+ + setSettingsJobTitle(e.target.value)} + onBlur={() => handleSettingsFieldSave('job_title', settingsJobTitle)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('job_title', settingsJobTitle); }} + maxLength={255} + /> +
+
+
+
+ ); +} diff --git a/frontend/src/components/settings/SecurityTab.tsx b/frontend/src/components/settings/SecurityTab.tsx new file mode 100644 index 0000000..22c9627 --- /dev/null +++ b/frontend/src/components/settings/SecurityTab.tsx @@ -0,0 +1,93 @@ +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent } from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import TotpSetupSection from './TotpSetupSection'; +import type { Settings } from '@/types'; + +interface SecurityTabProps { + settings: Settings | undefined; + updateSettings: (updates: Partial) => Promise; + isUpdating: boolean; +} + +export default function SecurityTab({ settings, updateSettings, isUpdating }: SecurityTabProps) { + const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false); + const [autoLockMinutes, setAutoLockMinutes] = useState(settings?.auto_lock_minutes ?? 5); + + useEffect(() => { + if (settings) { + setAutoLockEnabled(settings.auto_lock_enabled); + setAutoLockMinutes(settings.auto_lock_minutes ?? 5); + } + }, [settings?.id]); + + const handleAutoLockToggle = async (checked: boolean) => { + const previous = autoLockEnabled; + setAutoLockEnabled(checked); + try { + await updateSettings({ auto_lock_enabled: checked }); + toast.success(checked ? 'Auto-lock enabled' : 'Auto-lock disabled'); + } catch { + setAutoLockEnabled(previous); + toast.error('Failed to update auto-lock setting'); + } + }; + + const handleAutoLockMinutesSave = async () => { + const raw = typeof autoLockMinutes === 'string' ? parseInt(autoLockMinutes) : autoLockMinutes; + const clamped = Math.max(1, Math.min(60, isNaN(raw) ? 5 : raw)); + setAutoLockMinutes(clamped); + if (clamped === settings?.auto_lock_minutes) return; + try { + await updateSettings({ auto_lock_minutes: clamped }); + toast.success(`Auto-lock timeout set to ${clamped} minutes`); + } catch { + setAutoLockMinutes(settings?.auto_lock_minutes ?? 5); + toast.error('Failed to update auto-lock timeout'); + } + }; + + return ( +
+ {/* Auto-lock */} + + +
+
+ +

+ Automatically lock the screen after idle time +

+
+ +
+
+ + setAutoLockMinutes(e.target.value === '' ? '' : parseInt(e.target.value) || '')} + onBlur={handleAutoLockMinutesSave} + onKeyDown={(e) => { if (e.key === 'Enter') handleAutoLockMinutesSave(); }} + className="w-20" + disabled={!autoLockEnabled || isUpdating} + /> + minutes +
+
+
+ + {/* Password + TOTP */} + +
+ ); +} diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index 46e47eb..875a37f 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -1,893 +1,101 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; -import { toast } from 'sonner'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useSearchParams } from 'react-router-dom'; import { Settings, User, Palette, - Cloud, - CalendarDays, - LayoutDashboard, - MapPin, - X, - Search, - Loader2, Shield, Blocks, Ghost, } from 'lucide-react'; -import { useSettings } from '@/hooks/useSettings'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { DatePicker } from '@/components/ui/date-picker'; -import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; -import api from '@/lib/api'; -import type { GeoLocation, UserProfile } from '@/types'; -import { Switch } from '@/components/ui/switch'; -import CopyableField from '@/components/shared/CopyableField'; -import TotpSetupSection from './TotpSetupSection'; -import NtfySettingsSection from './NtfySettingsSection'; +import { useSettings } from '@/hooks/useSettings'; +import ProfileTab from './ProfileTab'; +import AppearanceTab from './AppearanceTab'; +import SocialTab from './SocialTab'; +import SecurityTab from './SecurityTab'; +import IntegrationsTab from './IntegrationsTab'; -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' }, - { name: 'red', label: 'Red', color: '#ef4444' }, - { name: 'pink', label: 'Pink', color: '#ec4899' }, - { name: 'yellow', label: 'Yellow', color: '#eab308' }, -]; +const tabs = [ + { id: 'profile', label: 'Profile', icon: User }, + { id: 'appearance', label: 'Appearance', icon: Palette }, + { id: 'social', label: 'Social', icon: Ghost }, + { id: 'security', label: 'Security', icon: Shield }, + { id: 'integrations', label: 'Integrations', icon: Blocks }, +] as const; + +type TabId = (typeof tabs)[number]['id']; export default function SettingsPage() { - const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); 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([]); - const [isSearching, setIsSearching] = useState(false); - const [showDropdown, setShowDropdown] = useState(false); - const searchRef = useRef(null); - const debounceRef = useRef>(); - const [firstDayOfWeek, setFirstDayOfWeek] = useState(settings?.first_day_of_week ?? 0); - const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false); - const [autoLockMinutes, setAutoLockMinutes] = useState(settings?.auto_lock_minutes ?? 5); + const rawTab = searchParams.get('tab'); + const activeTab: TabId = tabs.some((t) => t.id === rawTab) ? (rawTab as TabId) : 'profile'; - // Profile extension fields (stored on Settings model) - const [settingsPhone, setSettingsPhone] = useState(settings?.phone ?? ''); - const [settingsMobile, setSettingsMobile] = useState(settings?.mobile ?? ''); - const [settingsAddress, setSettingsAddress] = useState(settings?.address ?? ''); - const [settingsCompany, setSettingsCompany] = useState(settings?.company ?? ''); - const [settingsJobTitle, setSettingsJobTitle] = useState(settings?.job_title ?? ''); - - // Social settings - const [acceptConnections, setAcceptConnections] = useState(settings?.accept_connections ?? false); - const [shareFirstName, setShareFirstName] = useState(settings?.share_first_name ?? false); - const [shareLastName, setShareLastName] = useState(settings?.share_last_name ?? false); - const [sharePreferredName, setSharePreferredName] = useState(settings?.share_preferred_name ?? true); - const [shareEmail, setShareEmail] = useState(settings?.share_email ?? false); - const [sharePhone, setSharePhone] = useState(settings?.share_phone ?? false); - const [shareMobile, setShareMobile] = useState(settings?.share_mobile ?? false); - const [shareBirthday, setShareBirthday] = useState(settings?.share_birthday ?? false); - const [shareAddress, setShareAddress] = useState(settings?.share_address ?? false); - const [shareCompany, setShareCompany] = useState(settings?.share_company ?? false); - const [shareJobTitle, setShareJobTitle] = useState(settings?.share_job_title ?? false); - - // Profile fields (stored on User model, fetched from /auth/profile) - const profileQuery = useQuery({ - queryKey: ['profile'], - queryFn: async () => { - const { data } = await api.get('/auth/profile'); - return data; - }, - }); - const [firstName, setFirstName] = useState(''); - const [lastName, setLastName] = useState(''); - const [profileEmail, setProfileEmail] = useState(''); - const [dateOfBirth, setDateOfBirth] = useState(''); - const [emailError, setEmailError] = useState(null); - const [umbralName, setUmbralName] = useState(''); - const [umbralNameError, setUmbralNameError] = useState(null); - - useEffect(() => { - if (profileQuery.data) { - setFirstName(profileQuery.data.first_name ?? ''); - setLastName(profileQuery.data.last_name ?? ''); - setProfileEmail(profileQuery.data.email ?? ''); - setDateOfBirth(profileQuery.data.date_of_birth ?? ''); - setUmbralName(profileQuery.data.umbral_name ?? ''); - } - }, [profileQuery.dataUpdatedAt]); - - // 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); - setAutoLockEnabled(settings.auto_lock_enabled); - setAutoLockMinutes(settings.auto_lock_minutes ?? 5); - setSettingsPhone(settings.phone ?? ''); - setSettingsMobile(settings.mobile ?? ''); - setSettingsAddress(settings.address ?? ''); - setSettingsCompany(settings.company ?? ''); - setSettingsJobTitle(settings.job_title ?? ''); - setAcceptConnections(settings.accept_connections); - setShareFirstName(settings.share_first_name); - setShareLastName(settings.share_last_name); - setSharePreferredName(settings.share_preferred_name); - setShareEmail(settings.share_email); - setSharePhone(settings.share_phone); - setShareMobile(settings.share_mobile); - setShareBirthday(settings.share_birthday); - setShareAddress(settings.share_address); - setShareCompany(settings.share_company); - setShareJobTitle(settings.share_job_title); - } - }, [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('/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 handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth' | 'umbral_name') => { - const values: Record = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth, umbral_name: umbralName }; - const current = values[field].trim(); - const original = profileQuery.data?.[field] ?? ''; - if (current === (original || '')) return; - - // Client-side email validation - if (field === 'email' && current) { - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(current)) { - setEmailError('Invalid email format'); - return; - } - } - setEmailError(null); - - // Client-side umbral name validation - if (field === 'umbral_name') { - if (current.includes(' ')) { - setUmbralNameError('Must be a single word with no spaces'); - return; - } - if (!current || !/^[a-zA-Z0-9_-]{3,50}$/.test(current)) { - setUmbralNameError('3-50 characters: letters, numbers, hyphens, underscores'); - return; - } - setUmbralNameError(null); - } - - try { - await api.put('/auth/profile', { [field]: current || null }); - queryClient.invalidateQueries({ queryKey: ['profile'] }); - toast.success('Profile updated'); - } catch (err: any) { - const detail = err?.response?.data?.detail; - if (field === 'email' && detail) { - setEmailError(typeof detail === 'string' ? detail : 'Failed to update email'); - } else if (field === 'umbral_name' && detail) { - setUmbralNameError(typeof detail === 'string' ? detail : 'Failed to update umbral name'); - } else { - toast.error(typeof detail === 'string' ? detail : 'Failed to update profile'); - } - } - }; - - 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'); - } - }; - - const handleAutoLockToggle = async (checked: boolean) => { - const previous = autoLockEnabled; - setAutoLockEnabled(checked); - try { - await updateSettings({ auto_lock_enabled: checked }); - toast.success(checked ? 'Auto-lock enabled' : 'Auto-lock disabled'); - } catch { - setAutoLockEnabled(previous); - toast.error('Failed to update auto-lock setting'); - } - }; - - const handleSettingsFieldSave = async (field: string, value: string) => { - const trimmed = value.trim(); - const currentVal = (settings as any)?.[field] || ''; - if (trimmed === (currentVal || '')) return; - try { - await updateSettings({ [field]: trimmed || null } as any); - toast.success('Profile updated'); - } catch { - toast.error('Failed to update profile'); - } - }; - - const handleSocialToggle = async (field: string, checked: boolean, setter: (v: boolean) => void) => { - const previous = (settings as any)?.[field]; - setter(checked); - try { - await updateSettings({ [field]: checked } as any); - } catch { - setter(previous); - toast.error('Failed to update setting'); - } - }; - - const handleAutoLockMinutesSave = async () => { - const raw = typeof autoLockMinutes === 'string' ? parseInt(autoLockMinutes) : autoLockMinutes; - const clamped = Math.max(1, Math.min(60, isNaN(raw) ? 5 : raw)); - setAutoLockMinutes(clamped); - if (clamped === settings?.auto_lock_minutes) return; - try { - await updateSettings({ auto_lock_minutes: clamped }); - toast.success(`Auto-lock timeout set to ${clamped} minutes`); - } catch { - setAutoLockMinutes(settings?.auto_lock_minutes ?? 5); - toast.error('Failed to update auto-lock timeout'); - } + const handleTabChange = (tab: TabId) => { + setSearchParams({ tab }, { replace: true }); }; return (
- {/* Page header — matches Stage 4-5 pages */} -
-
+ ); + + return ( + <> + {bare ? ( + + + {content} + + + ) : ( + + +
+
+
+
+ Authentication + Manage your password and two-factor authentication +
+
+
+ + {content} + +
+ )} {/* Disable TOTP Dialog */} diff --git a/frontend/src/components/shared/EntityTable.tsx b/frontend/src/components/shared/EntityTable.tsx index 529493d..22f5823 100644 --- a/frontend/src/components/shared/EntityTable.tsx +++ b/frontend/src/components/shared/EntityTable.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; +import { ArrowUpDown, ArrowUp, ArrowDown, ChevronsUpDown } from 'lucide-react'; import type { VisibilityMode } from '@/hooks/useTableVisibility'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useMediaQuery, MOBILE } from '@/hooks/useMediaQuery'; export interface ColumnDef { key: string; @@ -134,11 +134,30 @@ export function EntityTable({ const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode)); const colCount = visibleColumns.length; const showPinnedSection = showPinned && pinnedRows.length > 0; - const isMobile = useMediaQuery('(max-width: 767px)'); + const isMobile = useMediaQuery(MOBILE); + + const sortableColumns = columns.filter((col) => col.sortable); if (isMobile && mobileCardRender) { return (
+ {sortableColumns.length > 0 && !loading && ( +
+ + +
+ )} {loading ? ( Array.from({ length: 6 }).map((_, i) => (
diff --git a/frontend/src/components/shared/MobileDetailOverlay.tsx b/frontend/src/components/shared/MobileDetailOverlay.tsx new file mode 100644 index 0000000..93019ff --- /dev/null +++ b/frontend/src/components/shared/MobileDetailOverlay.tsx @@ -0,0 +1,67 @@ +import { useEffect, useRef } from 'react'; +import { cn } from '@/lib/utils'; + +interface MobileDetailOverlayProps { + open: boolean; + onClose: () => void; + children: React.ReactNode; + className?: string; +} + +/** + * Full-screen overlay for mobile detail panels. + * - Backdrop click closes the overlay + * - Escape key closes the overlay + * - Body scroll is locked while open + */ +export default function MobileDetailOverlay({ + open, + onClose, + children, + className, +}: MobileDetailOverlayProps) { + // Stable ref to avoid re-registering listener on every render + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + // Escape key handler + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onCloseRef.current(); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open]); + + // Body scroll lock + useEffect(() => { + if (!open) return; + const previous = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previous; + }; + }, [open]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > + {children} +
+
+ ); +} diff --git a/frontend/src/components/todos/TodosPage.tsx b/frontend/src/components/todos/TodosPage.tsx index 1090915..699fc6d 100644 --- a/frontend/src/components/todos/TodosPage.tsx +++ b/frontend/src/components/todos/TodosPage.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useEffect } from 'react'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery'; import { useLocation } from 'react-router-dom'; import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; @@ -11,6 +11,7 @@ import { Select } from '@/components/ui/select'; import { Card, CardContent } from '@/components/ui/card'; import { ListSkeleton } from '@/components/ui/skeleton'; import { CategoryFilterBar } from '@/components/shared'; +import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay'; import { useCategoryOrder } from '@/hooks/useCategoryOrder'; import TodoList from './TodoList'; import TodoDetailPanel from './TodoDetailPanel'; @@ -26,7 +27,7 @@ const priorityFilters = [ export default function TodosPage() { const location = useLocation(); - const isDesktop = useMediaQuery('(min-width: 1024px)'); + const isDesktop = useMediaQuery(DESKTOP); // Panel state const [selectedTodoId, setSelectedTodoId] = useState(null); @@ -270,22 +271,14 @@ export default function TodosPage() { {/* Mobile detail panel overlay */} {panelOpen && !isDesktop && ( -
-
e.stopPropagation()} - > - -
-
+ + + )}
); diff --git a/frontend/src/components/ui/date-picker.tsx b/frontend/src/components/ui/date-picker.tsx index 4f9f7bb..5ed33ae 100644 --- a/frontend/src/components/ui/date-picker.tsx +++ b/frontend/src/components/ui/date-picker.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { createPortal } from 'react-dom'; import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useMediaQuery, MOBILE } from '@/hooks/useMediaQuery'; // ── Browser detection (stable — checked once at module load) ── @@ -128,7 +128,7 @@ const DatePicker = React.forwardRef( const blurTimeoutRef = React.useRef>(); const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 }); - const isMobile = useMediaQuery('(max-width: 767px)'); + const isMobile = useMediaQuery(MOBILE); React.useImperativeHandle(ref, () => triggerRef.current!); diff --git a/frontend/src/hooks/useMediaQuery.ts b/frontend/src/hooks/useMediaQuery.ts index 6fc4f90..ff8515c 100644 --- a/frontend/src/hooks/useMediaQuery.ts +++ b/frontend/src/hooks/useMediaQuery.ts @@ -1,5 +1,8 @@ import { useState, useEffect } from 'react'; +export const MOBILE = '(max-width: 767px)'; +export const DESKTOP = '(min-width: 1024px)'; + export function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState(() => typeof window !== 'undefined' ? window.matchMedia(query).matches : false diff --git a/frontend/src/index.css b/frontend/src/index.css index 267310c..8e6005e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -292,6 +292,12 @@ form[data-submitted] input:invalid + button { } +/* + * Mobile font scaling — overrides Tailwind text utilities below 768px. + * Applied via .mobile-scale class on
in AppLayout.tsx. + * These selectors rely on cascade order (loaded after Tailwind utilities). + * Arbitrary sizes like text-[9px] are NOT captured — see W-03 override below. + */ /* ── Global mobile content scaling ── */ /* Scales down all page content text on mobile. Navbar and UMBRA title are excluded because they live outside .mobile-scale and use their own sizing. */ @@ -323,6 +329,10 @@ form[data-submitted] input:invalid + button { .mobile-scale .text-3xl { font-size: 1.375rem; /* 22px */ } + /* W-03: Enforce 10px floor for arbitrary small sizes */ + .mobile-scale [class*="text-\[9px\]"] { + font-size: 10px !important; + } } /* ── Mobile touch optimisation ── */