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 { 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,35 +509,79 @@ 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="h-full">
|
<div
|
||||||
<FullCalendar
|
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||||
key={`fc-${settings?.first_day_of_week ?? 0}`}
|
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
||||||
ref={calendarRef}
|
}`}
|
||||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
>
|
||||||
initialView="dayGridMonth"
|
<div className="h-full">
|
||||||
headerToolbar={false}
|
<FullCalendar
|
||||||
firstDay={settings?.first_day_of_week ?? 0}
|
key={`fc-${settings?.first_day_of_week ?? 0}`}
|
||||||
events={calendarEvents}
|
ref={calendarRef}
|
||||||
editable={true}
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||||
selectable={true}
|
initialView="dayGridMonth"
|
||||||
selectMirror={true}
|
headerToolbar={false}
|
||||||
unselectAuto={false}
|
firstDay={settings?.first_day_of_week ?? 0}
|
||||||
dayMaxEvents={true}
|
events={calendarEvents}
|
||||||
weekends={true}
|
editable={true}
|
||||||
nowIndicator={true}
|
selectable={true}
|
||||||
eventClick={handleEventClick}
|
selectMirror={true}
|
||||||
eventDrop={handleEventDrop}
|
unselectAuto={false}
|
||||||
eventResize={handleEventResize}
|
dayMaxEvents={true}
|
||||||
select={handleDateSelect}
|
weekends={true}
|
||||||
datesSet={handleDatesSet}
|
nowIndicator={true}
|
||||||
height="100%"
|
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>
|
</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
|
||||||
event={editingEvent}
|
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 { 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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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')}>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user