Compare commits
No commits in common. "1291807847efeb63db7d737641f3eb161f99f99c" and "e51b09f9c537ff4203409629bbc84f65198a303d" have entirely different histories.
1291807847
...
e51b09f9c5
@ -19,18 +19,15 @@ export default function AdminPortal() {
|
||||
{/* Portal header with tab navigation */}
|
||||
<div className="shrink-0 border-b bg-card overflow-hidden">
|
||||
<div className="px-3 md:px-6 h-14 md:h-16 flex items-center gap-2 md:gap-4">
|
||||
<div className="flex items-center gap-2 shrink-0 md:mr-6">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="p-1.5 rounded-md bg-red-500/10">
|
||||
<ShieldCheck className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<h1 className="font-heading text-base md:text-2xl font-bold tracking-tight">
|
||||
<span className="hidden md:inline">Admin Portal</span>
|
||||
<span className="md:hidden">Admin</span>
|
||||
</h1>
|
||||
<h1 className="font-heading text-base md:text-2xl font-bold tracking-tight">Admin</h1>
|
||||
</div>
|
||||
|
||||
{/* Horizontal tab navigation — evenly spaced on mobile, left-aligned on desktop */}
|
||||
<nav className="flex items-center justify-evenly md:justify-start flex-1 md:flex-none md:gap-1 h-full min-w-0 overflow-hidden">
|
||||
{/* Horizontal tab navigation */}
|
||||
<nav className="flex items-center justify-evenly flex-1 h-full min-w-0 overflow-x-auto">
|
||||
{tabs.map(({ label, path, icon: Icon }) => {
|
||||
const isActive = location.pathname.startsWith(path);
|
||||
return (
|
||||
@ -40,7 +37,7 @@ export default function AdminPortal() {
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'flex items-center justify-center md:justify-start gap-1.5 px-2.5 md:px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px whitespace-nowrap',
|
||||
'flex items-center justify-center gap-1.5 px-2.5 md:px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px whitespace-nowrap',
|
||||
isActive
|
||||
? 'text-accent border-accent'
|
||||
: 'text-muted-foreground hover:text-foreground border-transparent'
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
@ -20,7 +20,6 @@ import { Select } from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
|
||||
import CalendarSidebar from './CalendarSidebar';
|
||||
import EventDetailPanel from './EventDetailPanel';
|
||||
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
|
||||
import type { CreateDefaults } from './EventDetailPanel';
|
||||
|
||||
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
|
||||
@ -165,7 +164,7 @@ export default function CalendarPage() {
|
||||
const panelOpen = panelMode !== 'closed';
|
||||
|
||||
// Track desktop breakpoint to prevent dual EventDetailPanel mount
|
||||
const isDesktop = useMediaQuery(DESKTOP);
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
// Continuously resize calendar during panel open/close CSS transition
|
||||
@ -640,18 +639,26 @@ export default function CalendarPage() {
|
||||
|
||||
{/* Mobile detail panel overlay */}
|
||||
{panelOpen && !isDesktop && (
|
||||
<MobileDetailOverlay open onClose={handlePanelClose} className="sm:max-w-[400px]">
|
||||
<EventDetailPanel
|
||||
event={panelMode === 'view' ? selectedEvent : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
createDefaults={createDefaults}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={handlePanelClose}
|
||||
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
||||
myPermission={selectedEventPermission}
|
||||
isSharedEvent={selectedEventIsShared}
|
||||
/>
|
||||
</MobileDetailOverlay>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||
onClick={handlePanelClose}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EventDetailPanel
|
||||
event={panelMode === 'view' ? selectedEvent : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
createDefaults={createDefaults}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={handlePanelClose}
|
||||
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
||||
myPermission={selectedEventPermission}
|
||||
isSharedEvent={selectedEventIsShared}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
||||
import { useMediaQuery } 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,11 +17,10 @@ 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(DESKTOP);
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||
|
||||
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@ -388,9 +387,17 @@ export default function LocationsPage() {
|
||||
|
||||
{/* Mobile detail panel overlay */}
|
||||
{panelOpen && selectedLocation && !isDesktop && (
|
||||
<MobileDetailOverlay open={true} onClose={() => setSelectedLocationId(null)}>
|
||||
{renderPanel()}
|
||||
</MobileDetailOverlay>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||
onClick={() => setSelectedLocationId(null)}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{renderPanel()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
||||
import { useMediaQuery } 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,7 +27,6 @@ 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
|
||||
@ -216,7 +215,7 @@ const panelFields: PanelField[] = [
|
||||
export default function PeoplePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isDesktop = useMediaQuery(DESKTOP);
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||
|
||||
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@ -777,9 +776,17 @@ export default function PeoplePage() {
|
||||
|
||||
{/* Mobile detail panel overlay */}
|
||||
{panelOpen && selectedPerson && !isDesktop && (
|
||||
<MobileDetailOverlay open={true} onClose={() => setSelectedPersonId(null)}>
|
||||
{renderPanel()}
|
||||
</MobileDetailOverlay>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||
onClick={() => setSelectedPersonId(null)}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{renderPanel()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
|
||||
@ -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: 8 } })
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
,
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } })
|
||||
);
|
||||
|
||||
// Subtask view is driven by kanbanParentTask (decoupled from selected task)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
@ -39,7 +39,6 @@ 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';
|
||||
@ -259,7 +258,7 @@ export default function ProjectDetail() {
|
||||
}
|
||||
}, [topLevelTasks, sortMode, sortSubtasks]);
|
||||
|
||||
const isDesktop = useMediaQuery(DESKTOP);
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||
|
||||
const selectedTask = useMemo(() => {
|
||||
if (!selectedTaskId) return null;
|
||||
@ -654,28 +653,30 @@ export default function ProjectDetail() {
|
||||
|
||||
{/* Mobile: show detail panel as overlay when task selected on small screens */}
|
||||
{selectedTaskId && selectedTask && !isDesktop && (
|
||||
<MobileDetailOverlay open={true} onClose={() => setSelectedTaskId(null)}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<span className="text-sm font-medium text-muted-foreground">Task Details</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedTaskId(null)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm">
|
||||
<div className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<span className="text-sm font-medium text-muted-foreground">Task Details</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedTaskId(null)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-[calc(100%-49px)]">
|
||||
<TaskDetailPanel
|
||||
task={selectedTask}
|
||||
projectId={parseInt(id!)}
|
||||
onDelete={handleDeleteTask}
|
||||
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
|
||||
onClose={() => setSelectedTaskId(null)}
|
||||
onSelectTask={setSelectedTaskId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[calc(100%-49px)]">
|
||||
<TaskDetailPanel
|
||||
task={selectedTask}
|
||||
projectId={parseInt(id!)}
|
||||
onDelete={handleDeleteTask}
|
||||
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
|
||||
onClose={() => setSelectedTaskId(null)}
|
||||
onSelectTask={setSelectedTaskId}
|
||||
/>
|
||||
</div>
|
||||
</MobileDetailOverlay>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTaskForm && (
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
||||
import { useMediaQuery } 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,7 +13,6 @@ 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' },
|
||||
@ -26,7 +25,7 @@ type StatusFilter = (typeof statusFilters)[number]['value'];
|
||||
export default function RemindersPage() {
|
||||
const location = useLocation();
|
||||
|
||||
const isDesktop = useMediaQuery(DESKTOP);
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||
|
||||
// Panel state
|
||||
const [selectedReminderId, setSelectedReminderId] = useState<number | null>(null);
|
||||
@ -236,14 +235,22 @@ export default function RemindersPage() {
|
||||
|
||||
{/* Mobile detail panel overlay */}
|
||||
{panelOpen && !isDesktop && (
|
||||
<MobileDetailOverlay open={true} onClose={handlePanelClose}>
|
||||
<ReminderDetailPanel
|
||||
reminder={panelMode === 'view' ? selectedReminder : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||
/>
|
||||
</MobileDetailOverlay>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||
onClick={handlePanelClose}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ReminderDetailPanel
|
||||
reminder={panelMode === 'view' ? selectedReminder : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,180 +0,0 @@
|
||||
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<Settings>) => Promise<Settings>;
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Accent Color */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-3">
|
||||
<Label>Accent Color</Label>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{accentColors.map((c) => (
|
||||
<button
|
||||
key={c.name}
|
||||
type="button"
|
||||
onClick={() => handleColorChange(c.name)}
|
||||
aria-pressed={selectedColor === c.name}
|
||||
aria-label={c.name}
|
||||
className="relative group p-1.5"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-full transition-all duration-150',
|
||||
selectedColor === c.name
|
||||
? 'ring-2 ring-offset-2 ring-offset-background'
|
||||
: 'hover:scale-110'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: c.color,
|
||||
...(selectedColor === c.name ? { '--tw-ring-color': c.color } as React.CSSProperties : {}),
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Calendar & Dashboard */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-5">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -1,182 +0,0 @@
|
||||
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<Settings> & { preferred_name?: string | null; ntfy_auth_token?: string }
|
||||
) => Promise<Settings>;
|
||||
}
|
||||
|
||||
export default function IntegrationsTab({ settings, updateSettings }: IntegrationsTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
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 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');
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Weather Location */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Weather 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>
|
||||
|
||||
{/* ntfy Push Notifications */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<NtfySettingsSection settings={settings} updateSettings={updateSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,269 +0,0 @@
|
||||
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<Settings> & { preferred_name?: string | null }) => Promise<Settings>;
|
||||
}
|
||||
|
||||
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<UserProfile>('/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<string | null>(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<string, string> = { 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 (
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
Personal Information
|
||||
</p>
|
||||
<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>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_name">First Name</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
type="text"
|
||||
placeholder="First name"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
onBlur={() => handleProfileSave('first_name')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('first_name'); }}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last_name">Last Name</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
type="text"
|
||||
placeholder="Last name"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
onBlur={() => handleProfileSave('last_name')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('last_name'); }}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile_email">Email</Label>
|
||||
<Input
|
||||
id="profile_email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={profileEmail}
|
||||
onChange={(e) => { 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 && (
|
||||
<p className="text-xs text-red-400">{emailError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date_of_birth">Date of Birth</Label>
|
||||
<DatePicker
|
||||
variant="input"
|
||||
id="date_of_birth"
|
||||
value={dateOfBirth}
|
||||
onChange={(v) => {
|
||||
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'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings_phone">Phone</Label>
|
||||
<Input
|
||||
id="settings_phone"
|
||||
type="tel"
|
||||
placeholder="Phone number"
|
||||
value={settingsPhone}
|
||||
onChange={(e) => setSettingsPhone(e.target.value)}
|
||||
onBlur={() => handleSettingsFieldSave('phone', settingsPhone)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('phone', settingsPhone); }}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings_mobile">Mobile</Label>
|
||||
<Input
|
||||
id="settings_mobile"
|
||||
type="tel"
|
||||
placeholder="Mobile number"
|
||||
value={settingsMobile}
|
||||
onChange={(e) => setSettingsMobile(e.target.value)}
|
||||
onBlur={() => handleSettingsFieldSave('mobile', settingsMobile)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('mobile', settingsMobile); }}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings_address">Address</Label>
|
||||
<Input
|
||||
id="settings_address"
|
||||
type="text"
|
||||
placeholder="Your address"
|
||||
value={settingsAddress}
|
||||
onChange={(e) => setSettingsAddress(e.target.value)}
|
||||
onBlur={() => handleSettingsFieldSave('address', settingsAddress)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('address', settingsAddress); }}
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings_company">Company</Label>
|
||||
<Input
|
||||
id="settings_company"
|
||||
type="text"
|
||||
placeholder="Company name"
|
||||
value={settingsCompany}
|
||||
onChange={(e) => setSettingsCompany(e.target.value)}
|
||||
onBlur={() => handleSettingsFieldSave('company', settingsCompany)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('company', settingsCompany); }}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings_job_title">Job Title</Label>
|
||||
<Input
|
||||
id="settings_job_title"
|
||||
type="text"
|
||||
placeholder="Your role"
|
||||
value={settingsJobTitle}
|
||||
onChange={(e) => setSettingsJobTitle(e.target.value)}
|
||||
onBlur={() => handleSettingsFieldSave('job_title', settingsJobTitle)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('job_title', settingsJobTitle); }}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -1,93 +0,0 @@
|
||||
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<Settings>) => Promise<Settings>;
|
||||
isUpdating: boolean;
|
||||
}
|
||||
|
||||
export default function SecurityTab({ settings, updateSettings, isUpdating }: SecurityTabProps) {
|
||||
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
|
||||
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(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 (
|
||||
<div className="space-y-6">
|
||||
{/* Auto-lock */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Auto-lock</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically lock the screen after idle time
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoLockEnabled}
|
||||
onCheckedChange={handleAutoLockToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="auto_lock_minutes" className="shrink-0">Lock after</Label>
|
||||
<Input
|
||||
id="auto_lock_minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={autoLockMinutes}
|
||||
onChange={(e) => setAutoLockMinutes(e.target.value === '' ? '' : parseInt(e.target.value) || '')}
|
||||
onBlur={handleAutoLockMinutesSave}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAutoLockMinutesSave(); }}
|
||||
className="w-20"
|
||||
disabled={!autoLockEnabled || isUpdating}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground shrink-0">minutes</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Password + TOTP */}
|
||||
<TotpSetupSection bare />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,101 +1,893 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Settings,
|
||||
User,
|
||||
Palette,
|
||||
Cloud,
|
||||
CalendarDays,
|
||||
LayoutDashboard,
|
||||
MapPin,
|
||||
X,
|
||||
Search,
|
||||
Loader2,
|
||||
Shield,
|
||||
Blocks,
|
||||
Ghost,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
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';
|
||||
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';
|
||||
|
||||
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'];
|
||||
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' },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { settings, updateSettings, isUpdating } = useSettings();
|
||||
|
||||
const rawTab = searchParams.get('tab');
|
||||
const activeTab: TabId = tabs.some((t) => t.id === rawTab) ? (rawTab as TabId) : 'profile';
|
||||
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);
|
||||
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
|
||||
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(settings?.auto_lock_minutes ?? 5);
|
||||
|
||||
const handleTabChange = (tab: TabId) => {
|
||||
setSearchParams({ tab }, { replace: true });
|
||||
// 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<UserProfile>('/auth/profile');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [profileEmail, setProfileEmail] = useState('');
|
||||
const [dateOfBirth, setDateOfBirth] = useState('');
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [umbralName, setUmbralName] = useState('');
|
||||
const [umbralNameError, setUmbralNameError] = useState<string | null>(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<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 handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth' | 'umbral_name') => {
|
||||
const values: Record<string, string> = { 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');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Header with tab navigation — mirrors AdminPortal pattern */}
|
||||
<div className="shrink-0 border-b bg-card overflow-hidden">
|
||||
<div className="px-3 md:px-6 h-14 md:h-16 flex items-center gap-2 md:gap-4">
|
||||
<div className="flex items-center gap-2 shrink-0 md:mr-6">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
<Settings className="h-5 w-5 text-accent" aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="font-heading text-base md:text-2xl font-bold tracking-tight">
|
||||
Settings
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<nav role="tablist" className="flex items-center justify-evenly md:justify-start flex-1 md:flex-none md:gap-1 h-full min-w-0 overflow-hidden">
|
||||
{tabs.map(({ id, label, icon: Icon }) => {
|
||||
const isActive = activeTab === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => handleTabChange(id)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-selected={isActive}
|
||||
role="tab"
|
||||
className={cn(
|
||||
'flex items-center justify-center md:justify-start gap-1.5 px-2.5 md:px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px whitespace-nowrap',
|
||||
isActive
|
||||
? 'text-accent border-accent'
|
||||
: 'text-muted-foreground hover:text-foreground border-transparent'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
{/* Page header — matches Stage 4-5 pages */}
|
||||
<div className="border-b bg-card px-4 md: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>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{activeTab === 'profile' && (
|
||||
<ProfileTab settings={settings} updateSettings={updateSettings} />
|
||||
)}
|
||||
{activeTab === 'appearance' && (
|
||||
<AppearanceTab settings={settings} updateSettings={updateSettings} isUpdating={isUpdating} />
|
||||
)}
|
||||
{activeTab === 'social' && (
|
||||
<SocialTab settings={settings} updateSettings={updateSettings} />
|
||||
)}
|
||||
{activeTab === 'security' && (
|
||||
<SecurityTab settings={settings} updateSettings={updateSettings} isUpdating={isUpdating} />
|
||||
)}
|
||||
{activeTab === 'integrations' && (
|
||||
<IntegrationsTab settings={settings} updateSettings={updateSettings} />
|
||||
)}
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
|
||||
{/* ── Left column: Profile, Appearance, Calendar, Dashboard, 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>Your profile and display preferences</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<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>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_name">First Name</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
type="text"
|
||||
placeholder="First name"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
onBlur={() => handleProfileSave('first_name')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('first_name'); }}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last_name">Last Name</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
type="text"
|
||||
placeholder="Last name"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
onBlur={() => handleProfileSave('last_name')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('last_name'); }}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile_email">Email</Label>
|
||||
<Input
|
||||
id="profile_email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={profileEmail}
|
||||
onChange={(e) => { 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 && (
|
||||
<p className="text-xs text-red-400">{emailError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date_of_birth">Date of Birth</Label>
|
||||
<DatePicker
|
||||
variant="input"
|
||||
id="date_of_birth"
|
||||
value={dateOfBirth}
|
||||
onChange={(v) => {
|
||||
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'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings_phone">Phone</Label>
|
||||
<Input
|
||||
id="settings_phone"
|
||||
type="tel"
|
||||
placeholder="Phone number"
|
||||
value={settingsPhone}
|
||||
onChange={(e) => setSettingsPhone(e.target.value)}
|
||||
onBlur={() => handleSettingsFieldSave('phone', settingsPhone)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('phone', settingsPhone); }}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings_mobile">Mobile</Label>
|
||||
<Input
|
||||
id="settings_mobile"
|
||||
type="tel"
|
||||
placeholder="Mobile number"
|
||||
value={settingsMobile}
|
||||
onChange={(e) => setSettingsMobile(e.target.value)}
|
||||
onBlur={() => handleSettingsFieldSave('mobile', settingsMobile)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('mobile', settingsMobile); }}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings_address">Address</Label>
|
||||
<Input
|
||||
id="settings_address"
|
||||
type="text"
|
||||
placeholder="Your address"
|
||||
value={settingsAddress}
|
||||
onChange={(e) => setSettingsAddress(e.target.value)}
|
||||
onBlur={() => handleSettingsFieldSave('address', settingsAddress)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('address', settingsAddress); }}
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings_company">Company</Label>
|
||||
<Input
|
||||
id="settings_company"
|
||||
type="text"
|
||||
placeholder="Company name"
|
||||
value={settingsCompany}
|
||||
onChange={(e) => setSettingsCompany(e.target.value)}
|
||||
onBlur={() => handleSettingsFieldSave('company', settingsCompany)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('company', settingsCompany); }}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings_job_title">Job Title</Label>
|
||||
<Input
|
||||
id="settings_job_title"
|
||||
type="text"
|
||||
placeholder="Your role"
|
||||
value={settingsJobTitle}
|
||||
onChange={(e) => setSettingsJobTitle(e.target.value)}
|
||||
onBlur={() => handleSettingsFieldSave('job_title', settingsJobTitle)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('job_title', settingsJobTitle); }}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
</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-4 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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: Social, Security, Authentication, Integrations ── */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Social */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-violet-500/10">
|
||||
<Ghost className="h-4 w-4 text-violet-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Social</CardTitle>
|
||||
<CardDescription>Manage your Umbra identity and connections</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="umbral_name">Umbral Name</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
id="umbral_name"
|
||||
value={umbralName}
|
||||
onChange={(e) => { setUmbralName(e.target.value); setUmbralNameError(null); }}
|
||||
onBlur={() => handleProfileSave('umbral_name')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('umbral_name'); }}
|
||||
maxLength={50}
|
||||
placeholder="Your discoverable name"
|
||||
className={umbralNameError ? 'border-red-500/50' : ''}
|
||||
/>
|
||||
<CopyableField value={umbralName} label="Umbral name" />
|
||||
</div>
|
||||
{umbralNameError ? (
|
||||
<p className="text-xs text-red-400">{umbralNameError}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How other Umbra users find you
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Accept Connections</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Allow other users to find and connect with you
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={acceptConnections}
|
||||
onCheckedChange={(checked) => handleSocialToggle('accept_connections', checked, setAcceptConnections)}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border pt-4 mt-4">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Sharing Defaults
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ field: 'share_first_name', label: 'First Name', state: shareFirstName, setter: setShareFirstName },
|
||||
{ field: 'share_last_name', label: 'Last Name', state: shareLastName, setter: setShareLastName },
|
||||
{ field: 'share_preferred_name', label: 'Preferred Name', state: sharePreferredName, setter: setSharePreferredName },
|
||||
{ field: 'share_email', label: 'Email', state: shareEmail, setter: setShareEmail },
|
||||
{ field: 'share_phone', label: 'Phone', state: sharePhone, setter: setSharePhone },
|
||||
{ field: 'share_mobile', label: 'Mobile', state: shareMobile, setter: setShareMobile },
|
||||
{ field: 'share_birthday', label: 'Birthday', state: shareBirthday, setter: setShareBirthday },
|
||||
{ field: 'share_address', label: 'Address', state: shareAddress, setter: setShareAddress },
|
||||
{ field: 'share_company', label: 'Company', state: shareCompany, setter: setShareCompany },
|
||||
{ field: 'share_job_title', label: 'Job Title', state: shareJobTitle, setter: setShareJobTitle },
|
||||
].map(({ field, label, state, setter }) => (
|
||||
<div key={field} className="flex items-center justify-between">
|
||||
<Label className="text-sm font-normal">{label}</Label>
|
||||
<Switch
|
||||
checked={state}
|
||||
onCheckedChange={(checked) => handleSocialToggle(field, checked, setter)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security (auto-lock) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-emerald-500/10">
|
||||
<Shield className="h-4 w-4 text-emerald-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Security</CardTitle>
|
||||
<CardDescription>Configure screen lock behavior</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Auto-lock</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically lock the screen after idle time
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoLockEnabled}
|
||||
onCheckedChange={handleAutoLockToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="auto_lock_minutes" className="shrink-0">Lock after</Label>
|
||||
<Input
|
||||
id="auto_lock_minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={autoLockMinutes}
|
||||
onChange={(e) => setAutoLockMinutes(e.target.value === '' ? '' : parseInt(e.target.value) || '')}
|
||||
onBlur={handleAutoLockMinutesSave}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAutoLockMinutesSave(); }}
|
||||
className="w-20"
|
||||
disabled={!autoLockEnabled || isUpdating}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground shrink-0">minutes</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Authentication (TOTP + password change) */}
|
||||
<TotpSetupSection />
|
||||
|
||||
{/* Integrations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-orange-500/10">
|
||||
<Blocks className="h-4 w-4 text-orange-400" aria-hidden="true" />
|
||||
</div>
|
||||
<CardTitle>Integrations</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<NtfySettingsSection settings={settings} updateSettings={updateSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,177 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
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 CopyableField from '@/components/shared/CopyableField';
|
||||
import api from '@/lib/api';
|
||||
import type { Settings, UserProfile } from '@/types';
|
||||
|
||||
interface SocialTabProps {
|
||||
settings: Settings | undefined;
|
||||
updateSettings: (updates: Partial<Settings>) => Promise<Settings>;
|
||||
}
|
||||
|
||||
export default function SocialTab({ settings, updateSettings }: SocialTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<UserProfile>('/auth/profile');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const [umbralName, setUmbralName] = useState('');
|
||||
const [umbralNameError, setUmbralNameError] = useState<string | null>(null);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileQuery.data) {
|
||||
setUmbralName(profileQuery.data.umbral_name ?? '');
|
||||
}
|
||||
}, [profileQuery.dataUpdatedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
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]);
|
||||
|
||||
const handleUmbralNameSave = async () => {
|
||||
const current = umbralName.trim();
|
||||
const original = profileQuery.data?.umbral_name ?? '';
|
||||
if (current === (original || '')) return;
|
||||
|
||||
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', { umbral_name: current });
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] });
|
||||
toast.success('Profile updated');
|
||||
} catch (err: any) {
|
||||
const detail = err?.response?.data?.detail;
|
||||
setUmbralNameError(typeof detail === 'string' ? detail : 'Failed to update umbral name');
|
||||
}
|
||||
};
|
||||
|
||||
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 sharingToggles = [
|
||||
{ field: 'share_first_name', label: 'First Name', state: shareFirstName, setter: setShareFirstName },
|
||||
{ field: 'share_last_name', label: 'Last Name', state: shareLastName, setter: setShareLastName },
|
||||
{ field: 'share_preferred_name', label: 'Preferred Name', state: sharePreferredName, setter: setSharePreferredName },
|
||||
{ field: 'share_email', label: 'Email', state: shareEmail, setter: setShareEmail },
|
||||
{ field: 'share_phone', label: 'Phone', state: sharePhone, setter: setSharePhone },
|
||||
{ field: 'share_mobile', label: 'Mobile', state: shareMobile, setter: setShareMobile },
|
||||
{ field: 'share_birthday', label: 'Birthday', state: shareBirthday, setter: setShareBirthday },
|
||||
{ field: 'share_address', label: 'Address', state: shareAddress, setter: setShareAddress },
|
||||
{ field: 'share_company', label: 'Company', state: shareCompany, setter: setShareCompany },
|
||||
{ field: 'share_job_title', label: 'Job Title', state: shareJobTitle, setter: setShareJobTitle },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Umbral Name */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="umbral_name">Umbral Name</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
id="umbral_name"
|
||||
value={umbralName}
|
||||
onChange={(e) => { setUmbralName(e.target.value); setUmbralNameError(null); }}
|
||||
onBlur={handleUmbralNameSave}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleUmbralNameSave(); }}
|
||||
maxLength={50}
|
||||
placeholder="Your discoverable name"
|
||||
className={umbralNameError ? 'border-red-500/50' : ''}
|
||||
/>
|
||||
<CopyableField value={umbralName} label="Umbral name" />
|
||||
</div>
|
||||
{umbralNameError ? (
|
||||
<p className="text-xs text-red-400">{umbralNameError}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How other Umbra users find you
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Accept Connections</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Allow other users to find and connect with you
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={acceptConnections}
|
||||
onCheckedChange={(checked) => handleSocialToggle('accept_connections', checked, setAcceptConnections)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sharing Defaults */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Sharing Defaults
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-4">
|
||||
{sharingToggles.map(({ field, label, state, setter }) => (
|
||||
<div key={field} className="flex items-center justify-between">
|
||||
<Label className="text-sm font-normal">{label}</Label>
|
||||
<Switch
|
||||
checked={state}
|
||||
onCheckedChange={(checked) => handleSocialToggle(field, checked, setter)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -26,11 +26,7 @@ import type { TotpSetupResponse } from '@/types';
|
||||
|
||||
type TotpSetupState = 'idle' | 'setup' | 'confirm' | 'backup_codes' | 'enabled';
|
||||
|
||||
interface TotpSetupSectionProps {
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
export default function TotpSetupSection({ bare = false }: TotpSetupSectionProps) {
|
||||
export default function TotpSetupSection() {
|
||||
// ── Password change state ──
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
oldPassword: '',
|
||||
@ -209,8 +205,21 @@ export default function TotpSetupSection({ bare = false }: TotpSetupSectionProps
|
||||
setDialogCode('');
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className="space-y-6">
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-red-500/10">
|
||||
<ShieldCheck className="h-4 w-4 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Authentication</CardTitle>
|
||||
<CardDescription>Manage your password and two-factor authentication</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
|
||||
{/* Subsection A: Change Password */}
|
||||
<div className="space-y-4">
|
||||
@ -234,7 +243,6 @@ export default function TotpSetupSection({ bare = false }: TotpSetupSectionProps
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
||||
autoComplete="new-password"
|
||||
minLength={12}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -416,35 +424,8 @@ export default function TotpSetupSection({ bare = false }: TotpSetupSectionProps
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{bare ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{content}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-red-500/10">
|
||||
<ShieldCheck className="h-4 w-4 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Authentication</CardTitle>
|
||||
<CardDescription>Manage your password and two-factor authentication</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{content}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disable TOTP Dialog */}
|
||||
<Dialog open={disableDialogOpen} onOpenChange={closeDialog}>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown, ChevronsUpDown } from 'lucide-react';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import type { VisibilityMode } from '@/hooks/useTableVisibility';
|
||||
import { useMediaQuery, MOBILE } from '@/hooks/useMediaQuery';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
|
||||
export interface ColumnDef<T> {
|
||||
key: string;
|
||||
@ -134,30 +134,11 @@ export function EntityTable<T extends { id: number }>({
|
||||
const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode));
|
||||
const colCount = visibleColumns.length;
|
||||
const showPinnedSection = showPinned && pinnedRows.length > 0;
|
||||
const isMobile = useMediaQuery(MOBILE);
|
||||
|
||||
const sortableColumns = columns.filter((col) => col.sortable);
|
||||
const isMobile = useMediaQuery('(max-width: 767px)');
|
||||
|
||||
if (isMobile && mobileCardRender) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sortableColumns.length > 0 && !loading && (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<select
|
||||
value={sortKey}
|
||||
onChange={(e) => onSort(e.target.value)}
|
||||
aria-label="Sort by"
|
||||
className="h-7 rounded-md border border-border bg-card px-2 text-xs text-foreground"
|
||||
>
|
||||
{sortableColumns.map((col) => (
|
||||
<option key={col.key} value={col.key}>
|
||||
{col.label} {sortKey === col.key ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse rounded-lg bg-card border border-border p-4 h-20" />
|
||||
|
||||
@ -1,67 +0,0 @@
|
||||
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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm animate-fade-in"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 top-0 h-full w-full sm:max-w-md bg-card border-l border-border shadow-xl overflow-y-auto',
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@ -11,7 +11,6 @@ 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';
|
||||
@ -27,7 +26,7 @@ const priorityFilters = [
|
||||
export default function TodosPage() {
|
||||
const location = useLocation();
|
||||
|
||||
const isDesktop = useMediaQuery(DESKTOP);
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||
|
||||
// Panel state
|
||||
const [selectedTodoId, setSelectedTodoId] = useState<number | null>(null);
|
||||
@ -271,14 +270,22 @@ export default function TodosPage() {
|
||||
|
||||
{/* Mobile detail panel overlay */}
|
||||
{panelOpen && !isDesktop && (
|
||||
<MobileDetailOverlay open={true} onClose={handlePanelClose}>
|
||||
<TodoDetailPanel
|
||||
todo={panelMode === 'view' ? selectedTodo : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||
/>
|
||||
</MobileDetailOverlay>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||
onClick={handlePanelClose}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TodoDetailPanel
|
||||
todo={panelMode === 'view' ? selectedTodo : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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, MOBILE } from '@/hooks/useMediaQuery';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
|
||||
// ── Browser detection (stable — checked once at module load) ──
|
||||
|
||||
@ -128,7 +128,7 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
|
||||
const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
||||
const isMobile = useMediaQuery(MOBILE);
|
||||
const isMobile = useMediaQuery('(max-width: 767px)');
|
||||
|
||||
React.useImperativeHandle(ref, () => triggerRef.current!);
|
||||
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
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
|
||||
|
||||
@ -292,12 +292,6 @@ form[data-submitted] input:invalid + button {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Mobile font scaling — overrides Tailwind text utilities below 768px.
|
||||
* Applied via .mobile-scale class on <main> 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. */
|
||||
@ -329,10 +323,6 @@ 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 ── */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user