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:
parent
89e253843c
commit
898ecc407a
@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
@ -6,12 +7,13 @@ import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
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 type { CalendarEvent, EventTemplate } from '@/types';
|
||||
import type { CalendarEvent, EventTemplate, Location as LocationType } from '@/types';
|
||||
import { useCalendars } from '@/hooks/useCalendars';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -20,6 +22,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import CalendarSidebar from './CalendarSidebar';
|
||||
import EventForm from './EventForm';
|
||||
import EventDetailPanel from './EventDetailPanel';
|
||||
|
||||
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
|
||||
|
||||
@ -33,6 +36,7 @@ type ScopeAction = 'edit' | 'delete';
|
||||
|
||||
export default function CalendarPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const calendarRef = useRef<FullCalendar>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null);
|
||||
@ -45,6 +49,10 @@ export default function CalendarPage() {
|
||||
const [templateEvent, setTemplateEvent] = useState<Partial<CalendarEvent> | 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
|
||||
const [scopeDialogOpen, setScopeDialogOpen] = useState(false);
|
||||
const [scopeAction, setScopeAction] = useState<ScopeAction>('edit');
|
||||
@ -55,6 +63,34 @@ export default function CalendarPage() {
|
||||
const { data: calendars = [] } = useCalendars();
|
||||
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)
|
||||
useEffect(() => {
|
||||
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(
|
||||
() => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)),
|
||||
[calendars],
|
||||
@ -163,6 +215,7 @@ export default function CalendarPage() {
|
||||
toast.success('Event(s) deleted');
|
||||
setScopeDialogOpen(false);
|
||||
setScopeEvent(null);
|
||||
setSelectedEventId(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
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));
|
||||
}, [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) => ({
|
||||
id: String(event.id),
|
||||
title: event.title,
|
||||
@ -200,15 +275,7 @@ export default function CalendarPage() {
|
||||
toast.info(`${event.title} — from People contacts`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRecurring(event)) {
|
||||
setScopeEvent(event);
|
||||
setScopeAction('edit');
|
||||
setScopeDialogOpen(true);
|
||||
} else {
|
||||
setEditingEvent(event);
|
||||
setShowForm(true);
|
||||
}
|
||||
setSelectedEventId(event.id);
|
||||
};
|
||||
|
||||
const handleScopeChoice = (scope: 'this' | 'this_and_future') => {
|
||||
@ -312,13 +379,53 @@ export default function CalendarPage() {
|
||||
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 navigateNext = () => calendarRef.current?.getApi().next();
|
||||
const navigateToday = () => calendarRef.current?.getApi().today();
|
||||
const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view);
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<div className="flex h-full overflow-hidden animate-fade-in">
|
||||
<CalendarSidebar onUseTemplate={handleUseTemplate} />
|
||||
|
||||
<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}>
|
||||
Today
|
||||
</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">
|
||||
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
|
||||
<button
|
||||
@ -357,35 +509,79 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="h-full">
|
||||
<FullCalendar
|
||||
key={`fc-${settings?.first_day_of_week ?? 0}`}
|
||||
ref={calendarRef}
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
headerToolbar={false}
|
||||
firstDay={settings?.first_day_of_week ?? 0}
|
||||
events={calendarEvents}
|
||||
editable={true}
|
||||
selectable={true}
|
||||
selectMirror={true}
|
||||
unselectAuto={false}
|
||||
dayMaxEvents={true}
|
||||
weekends={true}
|
||||
nowIndicator={true}
|
||||
eventClick={handleEventClick}
|
||||
eventDrop={handleEventDrop}
|
||||
eventResize={handleEventResize}
|
||||
select={handleDateSelect}
|
||||
datesSet={handleDatesSet}
|
||||
height="100%"
|
||||
{/* Calendar grid + event detail panel */}
|
||||
<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">
|
||||
<FullCalendar
|
||||
key={`fc-${settings?.first_day_of_week ?? 0}`}
|
||||
ref={calendarRef}
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
headerToolbar={false}
|
||||
firstDay={settings?.first_day_of_week ?? 0}
|
||||
events={calendarEvents}
|
||||
editable={true}
|
||||
selectable={true}
|
||||
selectMirror={true}
|
||||
unselectAuto={false}
|
||||
dayMaxEvents={true}
|
||||
weekends={true}
|
||||
nowIndicator={true}
|
||||
eventClick={handleEventClick}
|
||||
eventDrop={handleEventDrop}
|
||||
eventResize={handleEventResize}
|
||||
select={handleDateSelect}
|
||||
datesSet={handleDatesSet}
|
||||
height="100%"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 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 && (
|
||||
<EventForm
|
||||
event={editingEvent}
|
||||
|
||||
205
frontend/src/components/calendar/EventDetailPanel.tsx
Normal file
205
frontend/src/components/calendar/EventDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { format } from 'date-fns';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
@ -17,6 +18,9 @@ interface CalendarWidgetProps {
|
||||
}
|
||||
|
||||
export default function CalendarWidget({ events }: CalendarWidgetProps) {
|
||||
const navigate = useNavigate();
|
||||
const todayStr = format(new Date(), 'yyyy-MM-dd');
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@ -37,7 +41,8 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
|
||||
{events.map((event) => (
|
||||
<div
|
||||
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
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
|
||||
@ -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';
|
||||
|
||||
interface CountdownWidgetProps {
|
||||
@ -10,6 +11,7 @@ interface CountdownWidgetProps {
|
||||
}
|
||||
|
||||
export default function CountdownWidget({ events }: CountdownWidgetProps) {
|
||||
const navigate = useNavigate();
|
||||
const visible = events.filter((e) => differenceInCalendarDays(new Date(e.start_datetime), new Date()) >= 0);
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
@ -18,8 +20,13 @@ export default function CountdownWidget({ events }: CountdownWidgetProps) {
|
||||
{visible.map((event) => {
|
||||
const days = differenceInCalendarDays(new Date(event.start_datetime), new Date());
|
||||
const label = days === 0 ? 'Today' : days === 1 ? '1 day' : `${days} days`;
|
||||
const dateStr = format(new Date(event.start_datetime), 'yyyy-MM-dd');
|
||||
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" />
|
||||
<span className="text-sm text-amber-200/90 truncate">
|
||||
<span className="font-semibold tabular-nums">{label}</span>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { Bell, Plus, Calendar as CalIcon, ListTodo } from 'lucide-react';
|
||||
@ -33,6 +34,7 @@ function getGreeting(name?: string): string {
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { settings } = useSettings();
|
||||
const { alerts, dismiss: dismissAlert, snooze: snoozeAlert } = useAlerts();
|
||||
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
|
||||
@ -234,7 +236,8 @@ export default function DashboardPage() {
|
||||
{futureReminders.map((reminder) => (
|
||||
<div
|
||||
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" />
|
||||
<span className="font-medium text-sm truncate flex-1 min-w-0">{reminder.title}</span>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FolderKanban, TrendingUp, CheckSquare, CloudSun } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
@ -11,6 +12,8 @@ interface StatsWidgetProps {
|
||||
}
|
||||
|
||||
export default function StatsWidget({ projectStats, totalIncompleteTodos, weatherData }: StatsWidgetProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: 'PROJECTS',
|
||||
@ -18,6 +21,7 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
|
||||
icon: FolderKanban,
|
||||
color: 'text-blue-400',
|
||||
glowBg: 'bg-blue-500/10',
|
||||
onClick: () => navigate('/projects'),
|
||||
},
|
||||
{
|
||||
label: 'IN PROGRESS',
|
||||
@ -25,6 +29,7 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
|
||||
icon: TrendingUp,
|
||||
color: 'text-purple-400',
|
||||
glowBg: 'bg-purple-500/10',
|
||||
onClick: () => navigate('/projects', { state: { filter: 'in_progress' } }),
|
||||
},
|
||||
{
|
||||
label: 'OPEN TODOS',
|
||||
@ -32,13 +37,18 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
|
||||
icon: CheckSquare,
|
||||
color: 'text-teal-400',
|
||||
glowBg: 'bg-teal-500/10',
|
||||
onClick: () => navigate('/todos'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
|
||||
{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">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { format, isPast, endOfDay } from 'date-fns';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -29,6 +30,8 @@ const dotColors: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function TodoWidget({ todos }: TodoWidgetProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@ -51,7 +54,8 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
|
||||
return (
|
||||
<div
|
||||
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)} />
|
||||
<span className="text-sm font-medium truncate flex-1 min-w-0">{todo.title}</span>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { format } from 'date-fns';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CheckSquare, Calendar, Bell, ArrowRight } from 'lucide-react';
|
||||
import type { UpcomingItem } from '@/types';
|
||||
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) {
|
||||
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 (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
@ -44,7 +65,8 @@ export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps)
|
||||
return (
|
||||
<div
|
||||
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)} />
|
||||
<span className="text-sm font-medium truncate flex-1 min-w-0">{item.title}</span>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { format, startOfWeek, addDays, isSameDay, isBefore, startOfDay } from 'date-fns';
|
||||
import type { UpcomingItem } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -14,6 +15,7 @@ const typeColors: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function WeekTimeline({ items }: WeekTimelineProps) {
|
||||
const navigate = useNavigate();
|
||||
const today = useMemo(() => startOfDay(new Date()), []);
|
||||
const weekStart = useMemo(() => startOfWeek(today, { weekStartsOn: 1 }), [today]);
|
||||
|
||||
@ -41,12 +43,13 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
|
||||
{days.map((day) => (
|
||||
<div
|
||||
key={day.key}
|
||||
onClick={() => navigate('/calendar', { state: { date: day.key, view: 'timeGridDay' } })}
|
||||
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
|
||||
? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]'
|
||||
: day.isPast
|
||||
? 'border-transparent opacity-50'
|
||||
? 'border-transparent opacity-50 hover:opacity-75'
|
||||
: 'border-transparent hover:border-border/50'
|
||||
)}
|
||||
>
|
||||
|
||||
@ -10,7 +10,10 @@ import LockOverlay from './LockOverlay';
|
||||
|
||||
export default function AppLayout() {
|
||||
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);
|
||||
|
||||
return (
|
||||
@ -19,7 +22,11 @@ export default function AppLayout() {
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
onToggle={() => setCollapsed(!collapsed)}
|
||||
onToggle={() => {
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next));
|
||||
}}
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileClose={() => setMobileOpen(false)}
|
||||
/>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
@ -20,6 +20,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useLock } from '@/hooks/useLock';
|
||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import api from '@/lib/api';
|
||||
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 })),
|
||||
});
|
||||
|
||||
const handleLogout = async () => {
|
||||
const doLogout = useCallback(async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
}, [logout, navigate]);
|
||||
|
||||
const { confirming: logoutConfirming, handleClick: handleLogout } = useConfirmAction(doLogout);
|
||||
|
||||
const isProjectsActive = location.pathname.startsWith('/projects');
|
||||
const showExpanded = !collapsed || mobileOpen;
|
||||
@ -200,10 +203,15 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
|
||||
</NavLink>
|
||||
<button
|
||||
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" />
|
||||
{showExpanded && <span>Logout</span>}
|
||||
{showExpanded && <span>{logoutConfirming ? 'Sure?' : 'Logout'}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -283,7 +283,7 @@ export default function LocationsPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Header */}
|
||||
<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>
|
||||
|
||||
@ -400,7 +400,7 @@ export default function PeoplePage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Header */}
|
||||
<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>
|
||||
|
||||
@ -344,7 +344,7 @@ export default function ProjectDetail() {
|
||||
|
||||
if (isLoading) {
|
||||
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">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
|
||||
<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));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Header */}
|
||||
<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')}>
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, FolderKanban, Layers, PlayCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Plus, FolderKanban, Layers, PlayCircle, CheckCircle2, Search } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import type { Project } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { GridSkeleton } from '@/components/ui/skeleton';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
@ -18,9 +20,20 @@ const statusFilters = [
|
||||
] as const;
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const location = useLocation();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
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({
|
||||
queryKey: ['projects'],
|
||||
@ -30,9 +43,16 @@ export default function ProjectsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const filteredProjects = statusFilter
|
||||
? projects.filter((p) => p.status === statusFilter)
|
||||
: projects;
|
||||
const filteredProjects = useMemo(() => {
|
||||
let list = statusFilter ? projects.filter((p) => p.status === statusFilter) : 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 completedCount = projects.filter((p) => p.status === 'completed').length;
|
||||
@ -48,7 +68,7 @@ export default function ProjectsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Header */}
|
||||
<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>
|
||||
@ -75,6 +95,16 @@ export default function ProjectsPage() {
|
||||
|
||||
<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">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Project
|
||||
|
||||
@ -61,7 +61,7 @@ export default function RemindersPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Header */}
|
||||
<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>
|
||||
@ -87,7 +87,9 @@ export default function RemindersPage() {
|
||||
))}
|
||||
</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" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
@ -97,8 +99,6 @@ export default function RemindersPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button onClick={() => setShowForm(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Reminder
|
||||
|
||||
@ -210,7 +210,7 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
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 */}
|
||||
<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" />
|
||||
|
||||
@ -275,7 +275,7 @@ export default function CategoryFilterBar({
|
||||
onBlur={() => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 api from '@/lib/api';
|
||||
import type { Todo } from '@/types';
|
||||
@ -7,8 +7,6 @@ import { isTodoOverdue } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 TodoList from './TodoList';
|
||||
import TodoForm from './TodoForm';
|
||||
@ -76,9 +74,9 @@ export default function TodosPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* 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>
|
||||
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
|
||||
@ -102,7 +100,61 @@ export default function TodosPage() {
|
||||
))}
|
||||
</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" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
@ -112,37 +164,6 @@ export default function TodosPage() {
|
||||
/>
|
||||
</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">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Todo
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user