Stage 7: final polish — transitions, navigation, calendar panel

- Add animate-fade-in page transitions to all pages
- Persist sidebar collapsed state in localStorage
- Add two-click logout confirmation using useConfirmAction
- Restructure Todos header: replace <select> with pill filters, move search right
- Move Reminders search right-aligned with spacer
- Add event search dropdown + Create Event button to Calendar toolbar
- Add search input to Projects header with name/description filtering
- Fix CategoryFilterBar search focus ring clipping with ring-inset
- Create EventDetailPanel: read-only event view with copyable fields,
  recurrence display, edit/delete actions, location name resolution
- Refactor CalendarPage to 55/45 split-panel layout matching People/Locations
- Add mobile overlay panel for calendar event details
- Add navigation state handler for CalendarPage (date/view from dashboard)
- Add navigation state handler for ProjectsPage (status filter from dashboard)
- Make all dashboard widgets navigable: stat cards → pages, week timeline
  days → calendar day view, upcoming items → source pages, countdown items
  → calendar, today's events/todos/reminders → respective pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-25 22:08:08 +08:00
parent 89e253843c
commit 898ecc407a
19 changed files with 627 additions and 106 deletions

View File

@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useMemo } from 'react'; import { useState, useRef, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import FullCalendar from '@fullcalendar/react'; import FullCalendar from '@fullcalendar/react';
@ -6,12 +7,13 @@ import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid'; import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction'; import interactionPlugin from '@fullcalendar/interaction';
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'; import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
import { ChevronLeft, ChevronRight } from 'lucide-react'; import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent, EventTemplate } from '@/types'; import type { CalendarEvent, EventTemplate, Location as LocationType } from '@/types';
import { useCalendars } from '@/hooks/useCalendars'; import { useCalendars } from '@/hooks/useCalendars';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -20,6 +22,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import CalendarSidebar from './CalendarSidebar'; import CalendarSidebar from './CalendarSidebar';
import EventForm from './EventForm'; import EventForm from './EventForm';
import EventDetailPanel from './EventDetailPanel';
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay'; type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
@ -33,6 +36,7 @@ type ScopeAction = 'edit' | 'delete';
export default function CalendarPage() { export default function CalendarPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const location = useLocation();
const calendarRef = useRef<FullCalendar>(null); const calendarRef = useRef<FullCalendar>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null); const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null);
@ -45,6 +49,10 @@ export default function CalendarPage() {
const [templateEvent, setTemplateEvent] = useState<Partial<CalendarEvent> | null>(null); const [templateEvent, setTemplateEvent] = useState<Partial<CalendarEvent> | null>(null);
const [templateName, setTemplateName] = useState<string | null>(null); const [templateName, setTemplateName] = useState<string | null>(null);
const [eventSearch, setEventSearch] = useState('');
const [searchFocused, setSearchFocused] = useState(false);
const [selectedEventId, setSelectedEventId] = useState<number | string | null>(null);
// Scope dialog state // Scope dialog state
const [scopeDialogOpen, setScopeDialogOpen] = useState(false); const [scopeDialogOpen, setScopeDialogOpen] = useState(false);
const [scopeAction, setScopeAction] = useState<ScopeAction>('edit'); const [scopeAction, setScopeAction] = useState<ScopeAction>('edit');
@ -55,6 +63,34 @@ export default function CalendarPage() {
const { data: calendars = [] } = useCalendars(); const { data: calendars = [] } = useCalendars();
const calendarContainerRef = useRef<HTMLDivElement>(null); const calendarContainerRef = useRef<HTMLDivElement>(null);
// Location data for event panel
const { data: locations = [] } = useQuery({
queryKey: ['locations'],
queryFn: async () => {
const { data } = await api.get<LocationType[]>('/locations');
return data;
},
staleTime: 5 * 60 * 1000,
});
const locationMap = useMemo(() => {
const map = new Map<number, string>();
locations.forEach((l) => map.set(l.id, l.name));
return map;
}, [locations]);
// Handle navigation state from dashboard
useEffect(() => {
const state = location.state as { date?: string; view?: string } | null;
if (!state?.date) return;
const calApi = calendarRef.current?.getApi();
if (!calApi) return;
calApi.gotoDate(state.date);
if (state.view) calApi.changeView(state.view);
// Clear state to prevent re-triggering
window.history.replaceState({}, '');
}, [location.state]);
// Resize FullCalendar when container size changes (e.g. sidebar collapse) // Resize FullCalendar when container size changes (e.g. sidebar collapse)
useEffect(() => { useEffect(() => {
const el = calendarContainerRef.current; const el = calendarContainerRef.current;
@ -94,6 +130,22 @@ export default function CalendarPage() {
}, },
}); });
const panelOpen = selectedEventId !== null;
const selectedEvent = useMemo(
() => events.find((e) => e.id === selectedEventId) ?? null,
[selectedEventId, events],
);
// Escape key closes detail panel
useEffect(() => {
if (!panelOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setSelectedEventId(null);
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [panelOpen]);
const visibleCalendarIds = useMemo( const visibleCalendarIds = useMemo(
() => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)), () => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)),
[calendars], [calendars],
@ -163,6 +215,7 @@ export default function CalendarPage() {
toast.success('Event(s) deleted'); toast.success('Event(s) deleted');
setScopeDialogOpen(false); setScopeDialogOpen(false);
setScopeEvent(null); setScopeEvent(null);
setSelectedEventId(null);
}, },
onError: (error) => { onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete event')); toast.error(getErrorMessage(error, 'Failed to delete event'));
@ -174,6 +227,28 @@ export default function CalendarPage() {
return events.filter((e) => visibleCalendarIds.has(e.calendar_id)); return events.filter((e) => visibleCalendarIds.has(e.calendar_id));
}, [events, visibleCalendarIds, calendars.length]); }, [events, visibleCalendarIds, calendars.length]);
const searchResults = useMemo(() => {
if (!eventSearch.trim()) return [];
const q = eventSearch.toLowerCase();
return filteredEvents
.filter((e) => e.title.toLowerCase().includes(q))
.slice(0, 8);
}, [filteredEvents, eventSearch]);
const handleSearchSelect = (event: CalendarEvent) => {
const api = calendarRef.current?.getApi();
if (!api) return;
const startDate = new Date(event.start_datetime);
api.gotoDate(startDate);
if (event.all_day) {
api.changeView('dayGridMonth');
} else {
api.changeView('timeGridDay');
}
setEventSearch('');
setSearchFocused(false);
};
const calendarEvents = filteredEvents.map((event) => ({ const calendarEvents = filteredEvents.map((event) => ({
id: String(event.id), id: String(event.id),
title: event.title, title: event.title,
@ -200,15 +275,7 @@ export default function CalendarPage() {
toast.info(`${event.title} — from People contacts`); toast.info(`${event.title} — from People contacts`);
return; return;
} }
setSelectedEventId(event.id);
if (isRecurring(event)) {
setScopeEvent(event);
setScopeAction('edit');
setScopeDialogOpen(true);
} else {
setEditingEvent(event);
setShowForm(true);
}
}; };
const handleScopeChoice = (scope: 'this' | 'this_and_future') => { const handleScopeChoice = (scope: 'this' | 'this_and_future') => {
@ -312,13 +379,53 @@ export default function CalendarPage() {
setCurrentView(arg.view.type as CalendarView); setCurrentView(arg.view.type as CalendarView);
}; };
// Panel actions
const handlePanelEdit = () => {
if (!selectedEvent) return;
if (isRecurring(selectedEvent)) {
setScopeEvent(selectedEvent);
setScopeAction('edit');
setScopeDialogOpen(true);
} else {
setEditingEvent(selectedEvent);
setShowForm(true);
}
};
const handlePanelDelete = () => {
if (!selectedEvent) return;
if (isRecurring(selectedEvent)) {
setScopeEvent(selectedEvent);
setScopeAction('delete');
setScopeDialogOpen(true);
} else {
panelDeleteMutation.mutate(selectedEvent.id as number);
}
};
const panelDeleteMutation = useMutation({
mutationFn: async (id: number) => {
await api.delete(`/events/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success('Event deleted');
setSelectedEventId(null);
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete event'));
},
});
const navigatePrev = () => calendarRef.current?.getApi().prev(); const navigatePrev = () => calendarRef.current?.getApi().prev();
const navigateNext = () => calendarRef.current?.getApi().next(); const navigateNext = () => calendarRef.current?.getApi().next();
const navigateToday = () => calendarRef.current?.getApi().today(); const navigateToday = () => calendarRef.current?.getApi().today();
const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view); const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view);
return ( return (
<div className="flex h-full overflow-hidden"> <div className="flex h-full overflow-hidden animate-fade-in">
<CalendarSidebar onUseTemplate={handleUseTemplate} /> <CalendarSidebar onUseTemplate={handleUseTemplate} />
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden"> <div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
@ -335,7 +442,52 @@ export default function CalendarPage() {
<Button variant="outline" size="sm" className="h-8" onClick={navigateToday}> <Button variant="outline" size="sm" className="h-8" onClick={navigateToday}>
Today Today
</Button> </Button>
<h2 className="text-lg font-semibold font-heading flex-1">{calendarTitle}</h2> <h2 className="text-lg font-semibold font-heading">{calendarTitle}</h2>
<div className="flex-1" />
{/* Event search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search events..."
value={eventSearch}
onChange={(e) => setEventSearch(e.target.value)}
onFocus={() => setSearchFocused(true)}
onBlur={() => setTimeout(() => setSearchFocused(false), 200)}
className="w-52 h-8 pl-8 text-sm"
/>
{searchFocused && searchResults.length > 0 && (
<div className="absolute z-50 mt-1 w-72 right-0 rounded-md border bg-popover shadow-lg overflow-hidden">
{searchResults.map((event) => (
<button
key={event.id}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSearchSelect(event)}
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
>
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: event.calendar_color || 'hsl(var(--accent-color))' }}
/>
<div className="min-w-0 flex-1">
<span className="font-medium truncate block">{event.title}</span>
<span className="text-xs text-muted-foreground">
{new Date(event.start_datetime).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
</div>
</button>
))}
</div>
)}
</div>
<Button size="sm" onClick={() => setShowForm(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Event
</Button>
<div className="flex items-center rounded-md border border-border overflow-hidden"> <div className="flex items-center rounded-md border border-border overflow-hidden">
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => ( {(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
<button <button
@ -357,8 +509,13 @@ export default function CalendarPage() {
</div> </div>
</div> </div>
{/* Calendar grid */} {/* Calendar grid + event detail panel */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-hidden flex">
<div
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`}
>
<div className="h-full"> <div className="h-full">
<FullCalendar <FullCalendar
key={`fc-${settings?.first_day_of_week ?? 0}`} key={`fc-${settings?.first_day_of_week ?? 0}`}
@ -384,7 +541,46 @@ export default function CalendarPage() {
/> />
</div> </div>
</div> </div>
{/* Detail panel (desktop) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`}
>
<EventDetailPanel
event={selectedEvent}
onEdit={handlePanelEdit}
onDelete={handlePanelDelete}
deleteLoading={panelDeleteMutation.isPending}
onClose={() => setSelectedEventId(null)}
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
/>
</div> </div>
</div>
</div>
{/* Mobile detail panel overlay */}
{panelOpen && selectedEvent && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={() => setSelectedEventId(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()}
>
<EventDetailPanel
event={selectedEvent}
onEdit={handlePanelEdit}
onDelete={handlePanelDelete}
deleteLoading={panelDeleteMutation.isPending}
onClose={() => setSelectedEventId(null)}
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
/>
</div>
</div>
)}
{showForm && ( {showForm && (
<EventForm <EventForm

View File

@ -0,0 +1,205 @@
import { format, parseISO } from 'date-fns';
import { X, Pencil, Trash2, Clock, MapPin, AlignLeft, Repeat } from 'lucide-react';
import type { CalendarEvent } from '@/types';
import { Button } from '@/components/ui/button';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { formatUpdatedAt } from '@/components/shared/utils';
import CopyableField from '@/components/shared/CopyableField';
interface EventDetailPanelProps {
event: CalendarEvent | null;
onEdit: () => void;
onDelete: () => void;
deleteLoading?: boolean;
onClose: () => void;
locationName?: string;
}
function formatRecurrenceRule(rule: string): string {
try {
const parsed = JSON.parse(rule);
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
switch (parsed.type) {
case 'every_n_days':
return parsed.interval === 1 ? 'Every day' : `Every ${parsed.interval} days`;
case 'weekly':
return parsed.weekday != null ? `Weekly on ${weekdays[parsed.weekday]}` : 'Weekly';
case 'monthly_nth_weekday':
return parsed.week && parsed.weekday != null
? `Monthly on week ${parsed.week}, ${weekdays[parsed.weekday]}`
: 'Monthly';
case 'monthly_date':
return parsed.day ? `Monthly on the ${parsed.day}${ordinal(parsed.day)}` : 'Monthly';
default:
return 'Recurring';
}
} catch {
return 'Recurring';
}
}
function ordinal(n: number): string {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return s[(v - 20) % 10] || s[v] || s[0];
}
export default function EventDetailPanel({
event,
onEdit,
onDelete,
deleteLoading = false,
onClose,
locationName,
}: EventDetailPanelProps) {
const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete);
if (!event) return null;
const startDate = parseISO(event.start_datetime);
const endDate = event.end_datetime ? parseISO(event.end_datetime) : null;
const isRecurring = !!(event.is_recurring || event.parent_event_id);
const startStr = event.all_day
? format(startDate, 'EEEE, MMMM d, yyyy')
: format(startDate, 'EEEE, MMMM d, yyyy · h:mm a');
const endStr = endDate
? event.all_day
? format(endDate, 'EEEE, MMMM d, yyyy')
: format(endDate, 'h:mm a')
: null;
return (
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-border flex items-start justify-between">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: event.calendar_color || 'hsl(var(--accent-color))' }}
/>
<div className="min-w-0">
<h3 className="font-heading text-lg font-semibold truncate">{event.title}</h3>
<span className="text-xs text-muted-foreground">{event.calendar_name}</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close panel"
className="h-7 w-7 shrink-0 ml-2"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{/* Calendar */}
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Calendar</p>
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: event.calendar_color || 'hsl(var(--accent-color))' }}
/>
<span className="text-sm">{event.calendar_name}</span>
</div>
</div>
{/* Start */}
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Start</p>
<CopyableField value={startStr} icon={Clock} label="Start time" />
</div>
{/* End */}
{endStr && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">End</p>
<CopyableField value={endStr} icon={Clock} label="End time" />
</div>
)}
{/* Location */}
{locationName && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Location</p>
<CopyableField value={locationName} icon={MapPin} label="Location" />
</div>
)}
{/* Description */}
{event.description && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Description</p>
<div className="flex items-start gap-2">
<AlignLeft className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-sm whitespace-pre-wrap">{event.description}</p>
</div>
</div>
)}
{/* Recurrence */}
{isRecurring && event.recurrence_rule && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Recurrence</p>
<div className="flex items-center gap-2">
<Repeat className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm">{formatRecurrenceRule(event.recurrence_rule)}</span>
</div>
</div>
)}
{isRecurring && !event.recurrence_rule && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Recurrence</p>
<div className="flex items-center gap-2">
<Repeat className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm">Recurring event</span>
</div>
</div>
)}
</div>
{/* Footer */}
{!event.is_virtual && (
<div className="px-5 py-4 border-t border-border flex items-center justify-between">
<span className="text-[11px] text-muted-foreground">
{formatUpdatedAt(event.updated_at)}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit} aria-label="Edit">
<Pencil className="h-3.5 w-3.5 mr-1.5" />
Edit
</Button>
{confirming ? (
<Button
variant="ghost"
onClick={handleDelete}
disabled={deleteLoading}
aria-label="Confirm delete"
className="h-8 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
>
Sure?
</Button>
) : (
<Button
variant="ghost"
size="icon"
onClick={handleDelete}
disabled={deleteLoading}
aria-label="Delete"
className="h-8 w-8 hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -1,4 +1,5 @@
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Calendar } from 'lucide-react'; import { Calendar } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -17,6 +18,9 @@ interface CalendarWidgetProps {
} }
export default function CalendarWidget({ events }: CalendarWidgetProps) { export default function CalendarWidget({ events }: CalendarWidgetProps) {
const navigate = useNavigate();
const todayStr = format(new Date(), 'yyyy-MM-dd');
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@ -37,7 +41,8 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
{events.map((event) => ( {events.map((event) => (
<div <div
key={event.id} key={event.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150" onClick={() => navigate('/calendar', { state: { date: todayStr, view: 'timeGridDay' } })}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
> >
<div <div
className="w-1.5 h-1.5 rounded-full shrink-0" className="w-1.5 h-1.5 rounded-full shrink-0"

View File

@ -1,4 +1,5 @@
import { differenceInCalendarDays } from 'date-fns'; import { differenceInCalendarDays, format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Star } from 'lucide-react'; import { Star } from 'lucide-react';
interface CountdownWidgetProps { interface CountdownWidgetProps {
@ -10,6 +11,7 @@ interface CountdownWidgetProps {
} }
export default function CountdownWidget({ events }: CountdownWidgetProps) { export default function CountdownWidget({ events }: CountdownWidgetProps) {
const navigate = useNavigate();
const visible = events.filter((e) => differenceInCalendarDays(new Date(e.start_datetime), new Date()) >= 0); const visible = events.filter((e) => differenceInCalendarDays(new Date(e.start_datetime), new Date()) >= 0);
if (visible.length === 0) return null; if (visible.length === 0) return null;
@ -18,8 +20,13 @@ export default function CountdownWidget({ events }: CountdownWidgetProps) {
{visible.map((event) => { {visible.map((event) => {
const days = differenceInCalendarDays(new Date(event.start_datetime), new Date()); const days = differenceInCalendarDays(new Date(event.start_datetime), new Date());
const label = days === 0 ? 'Today' : days === 1 ? '1 day' : `${days} days`; const label = days === 0 ? 'Today' : days === 1 ? '1 day' : `${days} days`;
const dateStr = format(new Date(event.start_datetime), 'yyyy-MM-dd');
return ( return (
<div key={event.id} className="flex items-center gap-2"> <div
key={event.id}
onClick={() => navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay' } })}
className="flex items-center gap-2 cursor-pointer hover:bg-amber-500/10 rounded px-1 -mx-1 transition-colors duration-150"
>
<Star className="h-3 w-3 text-amber-400 fill-amber-400 shrink-0" /> <Star className="h-3 w-3 text-amber-400 fill-amber-400 shrink-0" />
<span className="text-sm text-amber-200/90 truncate"> <span className="text-sm text-amber-200/90 truncate">
<span className="font-semibold tabular-nums">{label}</span> <span className="font-semibold tabular-nums">{label}</span>

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Bell, Plus, Calendar as CalIcon, ListTodo } from 'lucide-react'; import { Bell, Plus, Calendar as CalIcon, ListTodo } from 'lucide-react';
@ -33,6 +34,7 @@ function getGreeting(name?: string): string {
} }
export default function DashboardPage() { export default function DashboardPage() {
const navigate = useNavigate();
const { settings } = useSettings(); const { settings } = useSettings();
const { alerts, dismiss: dismissAlert, snooze: snoozeAlert } = useAlerts(); const { alerts, dismiss: dismissAlert, snooze: snoozeAlert } = useAlerts();
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null); const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
@ -234,7 +236,8 @@ export default function DashboardPage() {
{futureReminders.map((reminder) => ( {futureReminders.map((reminder) => (
<div <div
key={reminder.id} key={reminder.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150" onClick={() => navigate('/reminders')}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
> >
<div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" /> <div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" />
<span className="font-medium text-sm truncate flex-1 min-w-0">{reminder.title}</span> <span className="font-medium text-sm truncate flex-1 min-w-0">{reminder.title}</span>

View File

@ -1,3 +1,4 @@
import { useNavigate } from 'react-router-dom';
import { FolderKanban, TrendingUp, CheckSquare, CloudSun } from 'lucide-react'; import { FolderKanban, TrendingUp, CheckSquare, CloudSun } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -11,6 +12,8 @@ interface StatsWidgetProps {
} }
export default function StatsWidget({ projectStats, totalIncompleteTodos, weatherData }: StatsWidgetProps) { export default function StatsWidget({ projectStats, totalIncompleteTodos, weatherData }: StatsWidgetProps) {
const navigate = useNavigate();
const statCards = [ const statCards = [
{ {
label: 'PROJECTS', label: 'PROJECTS',
@ -18,6 +21,7 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
icon: FolderKanban, icon: FolderKanban,
color: 'text-blue-400', color: 'text-blue-400',
glowBg: 'bg-blue-500/10', glowBg: 'bg-blue-500/10',
onClick: () => navigate('/projects'),
}, },
{ {
label: 'IN PROGRESS', label: 'IN PROGRESS',
@ -25,6 +29,7 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
icon: TrendingUp, icon: TrendingUp,
color: 'text-purple-400', color: 'text-purple-400',
glowBg: 'bg-purple-500/10', glowBg: 'bg-purple-500/10',
onClick: () => navigate('/projects', { state: { filter: 'in_progress' } }),
}, },
{ {
label: 'OPEN TODOS', label: 'OPEN TODOS',
@ -32,13 +37,18 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
icon: CheckSquare, icon: CheckSquare,
color: 'text-teal-400', color: 'text-teal-400',
glowBg: 'bg-teal-500/10', glowBg: 'bg-teal-500/10',
onClick: () => navigate('/todos'),
}, },
]; ];
return ( return (
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4"> <div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
{statCards.map((stat) => ( {statCards.map((stat) => (
<Card key={stat.label} className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <Card
key={stat.label}
className="bg-gradient-to-br from-accent/[0.03] to-transparent cursor-pointer group transition-transform duration-150 hover:scale-[1.01]"
onClick={stat.onClick}
>
<CardContent className="px-3 py-2"> <CardContent className="px-3 py-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">

View File

@ -1,4 +1,5 @@
import { format, isPast, endOfDay } from 'date-fns'; import { format, isPast, endOfDay } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { CheckCircle2 } from 'lucide-react'; import { CheckCircle2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -29,6 +30,8 @@ const dotColors: Record<string, string> = {
}; };
export default function TodoWidget({ todos }: TodoWidgetProps) { export default function TodoWidget({ todos }: TodoWidgetProps) {
const navigate = useNavigate();
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@ -51,7 +54,8 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
return ( return (
<div <div
key={todo.id} key={todo.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150" onClick={() => navigate('/todos')}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
> >
<div className={cn('w-1.5 h-1.5 rounded-full shrink-0', dotColors[todo.priority] || dotColors.medium)} /> <div className={cn('w-1.5 h-1.5 rounded-full shrink-0', dotColors[todo.priority] || dotColors.medium)} />
<span className="text-sm font-medium truncate flex-1 min-w-0">{todo.title}</span> <span className="text-sm font-medium truncate flex-1 min-w-0">{todo.title}</span>

View File

@ -1,4 +1,5 @@
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { CheckSquare, Calendar, Bell, ArrowRight } from 'lucide-react'; import { CheckSquare, Calendar, Bell, ArrowRight } from 'lucide-react';
import type { UpcomingItem } from '@/types'; import type { UpcomingItem } from '@/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -17,6 +18,26 @@ const typeConfig: Record<string, { icon: typeof CheckSquare; color: string; labe
}; };
export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) { export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) {
const navigate = useNavigate();
const handleItemClick = (item: UpcomingItem) => {
switch (item.type) {
case 'event': {
const dateStr = item.datetime
? format(new Date(item.datetime), 'yyyy-MM-dd')
: item.date;
navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay' } });
break;
}
case 'todo':
navigate('/todos');
break;
case 'reminder':
navigate('/reminders');
break;
}
};
return ( return (
<Card className="h-full"> <Card className="h-full">
<CardHeader> <CardHeader>
@ -44,7 +65,8 @@ export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps)
return ( return (
<div <div
key={`${item.type}-${item.id}-${index}`} key={`${item.type}-${item.id}-${index}`}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150" onClick={() => handleItemClick(item)}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
> >
<Icon className={cn('h-3.5 w-3.5 shrink-0', config.color)} /> <Icon className={cn('h-3.5 w-3.5 shrink-0', config.color)} />
<span className="text-sm font-medium truncate flex-1 min-w-0">{item.title}</span> <span className="text-sm font-medium truncate flex-1 min-w-0">{item.title}</span>

View File

@ -1,4 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { format, startOfWeek, addDays, isSameDay, isBefore, startOfDay } from 'date-fns'; import { format, startOfWeek, addDays, isSameDay, isBefore, startOfDay } from 'date-fns';
import type { UpcomingItem } from '@/types'; import type { UpcomingItem } from '@/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -14,6 +15,7 @@ const typeColors: Record<string, string> = {
}; };
export default function WeekTimeline({ items }: WeekTimelineProps) { export default function WeekTimeline({ items }: WeekTimelineProps) {
const navigate = useNavigate();
const today = useMemo(() => startOfDay(new Date()), []); const today = useMemo(() => startOfDay(new Date()), []);
const weekStart = useMemo(() => startOfWeek(today, { weekStartsOn: 1 }), [today]); const weekStart = useMemo(() => startOfWeek(today, { weekStartsOn: 1 }), [today]);
@ -41,12 +43,13 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
{days.map((day) => ( {days.map((day) => (
<div <div
key={day.key} key={day.key}
onClick={() => navigate('/calendar', { state: { date: day.key, view: 'timeGridDay' } })}
className={cn( className={cn(
'flex-1 flex flex-col items-center gap-1.5 rounded-lg py-3 px-2 transition-all duration-200 border', 'flex-1 flex flex-col items-center gap-1.5 rounded-lg py-3 px-2 transition-all duration-200 border cursor-pointer',
day.isToday day.isToday
? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]' ? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]'
: day.isPast : day.isPast
? 'border-transparent opacity-50' ? 'border-transparent opacity-50 hover:opacity-75'
: 'border-transparent hover:border-border/50' : 'border-transparent hover:border-border/50'
)} )}
> >

View File

@ -10,7 +10,10 @@ import LockOverlay from './LockOverlay';
export default function AppLayout() { export default function AppLayout() {
useTheme(); useTheme();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(() => {
try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); }
catch { return false; }
});
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
return ( return (
@ -19,7 +22,11 @@ export default function AppLayout() {
<div className="flex h-screen overflow-hidden bg-background"> <div className="flex h-screen overflow-hidden bg-background">
<Sidebar <Sidebar
collapsed={collapsed} collapsed={collapsed}
onToggle={() => setCollapsed(!collapsed)} onToggle={() => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next));
}}
mobileOpen={mobileOpen} mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)} onMobileClose={() => setMobileOpen(false)}
/> />

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useCallback } from 'react';
import { NavLink, useNavigate, useLocation } from 'react-router-dom'; import { NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
@ -20,6 +20,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useLock } from '@/hooks/useLock'; import { useLock } from '@/hooks/useLock';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Project } from '@/types'; import type { Project } from '@/types';
@ -57,10 +58,12 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
select: (data) => data.map(({ id, name }) => ({ id, name })), select: (data) => data.map(({ id, name }) => ({ id, name })),
}); });
const handleLogout = async () => { const doLogout = useCallback(async () => {
await logout(); await logout();
navigate('/login'); navigate('/login');
}; }, [logout, navigate]);
const { confirming: logoutConfirming, handleClick: handleLogout } = useConfirmAction(doLogout);
const isProjectsActive = location.pathname.startsWith('/projects'); const isProjectsActive = location.pathname.startsWith('/projects');
const showExpanded = !collapsed || mobileOpen; const showExpanded = !collapsed || mobileOpen;
@ -200,10 +203,15 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
</NavLink> </NavLink>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
logoutConfirming
? 'bg-destructive/15 text-destructive'
: 'text-muted-foreground hover:bg-destructive/10 hover:text-destructive'
)}
> >
<LogOut className="h-5 w-5 shrink-0" /> <LogOut className="h-5 w-5 shrink-0" />
{showExpanded && <span>Logout</span>} {showExpanded && <span>{logoutConfirming ? 'Sure?' : 'Logout'}</span>}
</button> </button>
</div> </div>
</> </>

View File

@ -283,7 +283,7 @@ export default function LocationsPage() {
); );
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1>

View File

@ -400,7 +400,7 @@ export default function PeoplePage() {
); );
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">People</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">People</h1>

View File

@ -344,7 +344,7 @@ export default function ProjectDetail() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}> <Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
@ -373,7 +373,7 @@ export default function ProjectDetail() {
project.due_date && project.status !== 'completed' && isPast(parseISO(project.due_date)); project.due_date && project.status !== 'completed' && isPast(parseISO(project.due_date));
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}> <Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>

View File

@ -1,9 +1,11 @@
import { useState } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { Plus, FolderKanban, Layers, PlayCircle, CheckCircle2 } from 'lucide-react'; import { useLocation } from 'react-router-dom';
import { Plus, FolderKanban, Layers, PlayCircle, CheckCircle2, Search } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Project } from '@/types'; import type { Project } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { GridSkeleton } from '@/components/ui/skeleton'; import { GridSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
@ -18,9 +20,20 @@ const statusFilters = [
] as const; ] as const;
export default function ProjectsPage() { export default function ProjectsPage() {
const location = useLocation();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null); const [editingProject, setEditingProject] = useState<Project | null>(null);
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const [search, setSearch] = useState('');
// Handle navigation state from dashboard
useEffect(() => {
const state = location.state as { filter?: string } | null;
if (state?.filter) {
setStatusFilter(state.filter);
window.history.replaceState({}, '');
}
}, [location.state]);
const { data: projects = [], isLoading } = useQuery({ const { data: projects = [], isLoading } = useQuery({
queryKey: ['projects'], queryKey: ['projects'],
@ -30,9 +43,16 @@ export default function ProjectsPage() {
}, },
}); });
const filteredProjects = statusFilter const filteredProjects = useMemo(() => {
? projects.filter((p) => p.status === statusFilter) let list = statusFilter ? projects.filter((p) => p.status === statusFilter) : projects;
: projects; if (search) {
const q = search.toLowerCase();
list = list.filter(
(p) => p.name.toLowerCase().includes(q) || p.description?.toLowerCase().includes(q)
);
}
return list;
}, [projects, statusFilter, search]);
const inProgressCount = projects.filter((p) => p.status === 'in_progress').length; const inProgressCount = projects.filter((p) => p.status === 'in_progress').length;
const completedCount = projects.filter((p) => p.status === 'completed').length; const completedCount = projects.filter((p) => p.status === 'completed').length;
@ -48,7 +68,7 @@ export default function ProjectsPage() {
}; };
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Projects</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">Projects</h1>
@ -75,6 +95,16 @@ export default function ProjectsPage() {
<div className="flex-1" /> <div className="flex-1" />
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-52 h-8 pl-8 text-sm"
/>
</div>
<Button onClick={() => setShowForm(true)} size="sm"> <Button onClick={() => setShowForm(true)} size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
New Project New Project

View File

@ -61,7 +61,7 @@ export default function RemindersPage() {
}; };
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Reminders</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">Reminders</h1>
@ -87,7 +87,9 @@ export default function RemindersPage() {
))} ))}
</div> </div>
<div className="relative ml-2"> <div className="flex-1" />
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input <Input
placeholder="Search..." placeholder="Search..."
@ -97,8 +99,6 @@ export default function RemindersPage() {
/> />
</div> </div>
<div className="flex-1" />
<Button onClick={() => setShowForm(true)} size="sm"> <Button onClick={() => setShowForm(true)} size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Reminder Add Reminder

View File

@ -210,7 +210,7 @@ export default function SettingsPage() {
}; };
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
{/* Page header — matches Stage 4-5 pages */} {/* Page header — matches Stage 4-5 pages */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-3 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-3 shrink-0">
<Settings className="h-5 w-5 text-accent" aria-hidden="true" /> <Settings className="h-5 w-5 text-accent" aria-hidden="true" />

View File

@ -275,7 +275,7 @@ export default function CategoryFilterBar({
onBlur={() => { onBlur={() => {
if (!searchValue && categories.length >= 4) setSearchCollapsed(true); if (!searchValue && categories.length >= 4) setSearchCollapsed(true);
}} }}
className="w-44 h-8 pl-8 text-sm" className="w-44 h-8 pl-8 text-sm ring-inset"
aria-label="Search" aria-label="Search"
/> />
</div> </div>

View File

@ -1,5 +1,5 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { Plus, CheckSquare, CheckCircle2, AlertCircle, Search, ChevronDown } from 'lucide-react'; import { Plus, CheckSquare, CheckCircle2, AlertCircle, Search } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Todo } from '@/types'; import type { Todo } from '@/types';
@ -7,8 +7,6 @@ import { isTodoOverdue } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { ListSkeleton } from '@/components/ui/skeleton'; import { ListSkeleton } from '@/components/ui/skeleton';
import TodoList from './TodoList'; import TodoList from './TodoList';
import TodoForm from './TodoForm'; import TodoForm from './TodoForm';
@ -76,9 +74,9 @@ export default function TodosPage() {
}; };
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0 flex-wrap">
<h1 className="font-heading text-2xl font-bold tracking-tight">Todos</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">Todos</h1>
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4"> <div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
@ -102,7 +100,61 @@ export default function TodosPage() {
))} ))}
</div> </div>
<div className="relative ml-2"> {/* Category pills */}
<div className="flex items-center rounded-md border border-border overflow-hidden">
<button
onClick={() => setFilters({ ...filters, category: '' })}
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
!filters.category
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor: !filters.category ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: !filters.category ? 'hsl(var(--accent-color))' : undefined,
}}
>
All
</button>
{categories.map((cat) => (
<button
key={cat}
onClick={() => setFilters({ ...filters, category: cat })}
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
filters.category === cat
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor: filters.category === cat ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: filters.category === cat ? 'hsl(var(--accent-color))' : undefined,
}}
>
{cat}
</button>
))}
</div>
{/* Completed toggle */}
<button
onClick={() => setFilters({ ...filters, showCompleted: !filters.showCompleted })}
className={`px-3 py-1.5 text-sm font-medium rounded-md border transition-colors duration-150 ${
filters.showCompleted
? 'border-accent/30 bg-accent/15 text-accent'
: 'border-border text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor: filters.showCompleted ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: filters.showCompleted ? 'hsl(var(--accent-color))' : undefined,
borderColor: filters.showCompleted ? 'hsl(var(--accent-color) / 0.3)' : undefined,
}}
>
Completed
</button>
<div className="flex-1" />
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input <Input
placeholder="Search..." placeholder="Search..."
@ -112,37 +164,6 @@ export default function TodosPage() {
/> />
</div> </div>
<div className="relative">
<select
value={filters.category}
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
className="h-8 rounded-md border border-input bg-background px-3 pr-8 text-sm text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 appearance-none cursor-pointer"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
</div>
<div className="flex items-center gap-1.5">
<Checkbox
id="show-completed"
checked={filters.showCompleted}
onChange={(e) =>
setFilters({ ...filters, showCompleted: (e.target as HTMLInputElement).checked })
}
/>
<Label htmlFor="show-completed" className="text-xs text-muted-foreground cursor-pointer">
Completed
</Label>
</div>
<div className="flex-1" />
<Button onClick={() => setShowForm(true)} size="sm"> <Button onClick={() => setShowForm(true)} size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Todo Add Todo