Add calendar redesign frontend with multi-calendar UI

- Custom toolbar replacing FullCalendar defaults (nav, today, view switcher)
- Calendar sidebar with visibility toggles, color dots, add/edit support
- CalendarForm dialog for creating/editing calendars with color swatches
- EventForm updated to use calendar dropdown instead of color picker
- CSS overrides: accent-tinted today highlight, now indicator, rounded event pills
- Types updated for Calendar interface and mixed id types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-21 19:14:06 +08:00
parent 093bceed06
commit 5b056cf674
7 changed files with 468 additions and 74 deletions

View File

@ -0,0 +1,144 @@
import { useState, FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { Calendar } from '@/types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
interface CalendarFormProps {
calendar: Calendar | null;
onClose: () => void;
}
const colorSwatches = [
'#3b82f6', // blue
'#ef4444', // red
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#8b5cf6', // purple
'#ec4899', // pink
'#06b6d4', // cyan
];
export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
const queryClient = useQueryClient();
const [name, setName] = useState(calendar?.name || '');
const [color, setColor] = useState(calendar?.color || '#3b82f6');
const mutation = useMutation({
mutationFn: async () => {
if (calendar) {
const { data } = await api.put(`/calendars/${calendar.id}`, { name, color });
return data;
} else {
const { data } = await api.post('/calendars', { name, color });
return data;
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendars'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
toast.success(calendar ? 'Calendar updated' : 'Calendar created');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to save calendar'));
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/calendars/${calendar?.id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendars'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
toast.success('Calendar deleted');
onClose();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete calendar'));
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
mutation.mutate();
};
const canDelete = calendar && !calendar.is_default && !calendar.is_system;
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{calendar ? 'Edit Calendar' : 'New Calendar'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cal-name">Name</Label>
<Input
id="cal-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Calendar name"
required
/>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex gap-2">
{colorSwatches.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className="h-8 w-8 rounded-full border-2 transition-all duration-150 hover:scale-110"
style={{
backgroundColor: c,
borderColor: color === c ? 'hsl(0 0% 98%)' : 'transparent',
boxShadow: color === c ? `0 0 0 2px ${c}40` : 'none',
}}
/>
))}
</div>
</div>
<DialogFooter>
{canDelete && (
<Button
type="button"
variant="destructive"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="mr-auto"
>
Delete
</Button>
)}
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : calendar ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -1,15 +1,27 @@
import { useState, useRef } from 'react';
import { useState, useRef, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import type { EventClickArg, DateSelectArg, EventDropArg } from '@fullcalendar/core';
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent } from '@/types';
import { useCalendars } from '@/hooks/useCalendars';
import { Button } from '@/components/ui/button';
import CalendarSidebar from './CalendarSidebar';
import EventForm from './EventForm';
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
const viewLabels: Record<CalendarView, string> = {
dayGridMonth: 'Month',
timeGridWeek: 'Week',
timeGridDay: 'Day',
};
export default function CalendarPage() {
const queryClient = useQueryClient();
const calendarRef = useRef<FullCalendar>(null);
@ -18,6 +30,10 @@ export default function CalendarPage() {
const [selectedStart, setSelectedStart] = useState<string | null>(null);
const [selectedEnd, setSelectedEnd] = useState<string | null>(null);
const [selectedAllDay, setSelectedAllDay] = useState(false);
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
const [calendarTitle, setCalendarTitle] = useState('');
const { data: calendars = [] } = useCalendars();
const { data: events = [] } = useQuery({
queryKey: ['calendar-events'],
@ -27,6 +43,11 @@ export default function CalendarPage() {
},
});
const visibleCalendarIds = useMemo(
() => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)),
[calendars],
);
const toLocalDatetime = (d: Date): string => {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
@ -39,7 +60,6 @@ export default function CalendarPage() {
allDay: boolean,
revert: () => void,
) => {
// Optimistically update cache so re-renders don't snap back
queryClient.setQueryData<CalendarEvent[]>(['calendar-events'], (old) =>
old?.map((e) =>
e.id === id
@ -47,7 +67,6 @@ export default function CalendarPage() {
: e,
),
);
eventMutation.mutate({ id, start, end, allDay, revert });
};
@ -82,25 +101,40 @@ export default function CalendarPage() {
},
});
const calendarEvents = events.map((event) => ({
id: event.id.toString(),
const filteredEvents = useMemo(() => {
if (calendars.length === 0) return events;
return events.filter((e) => visibleCalendarIds.has(e.calendar_id));
}, [events, visibleCalendarIds, calendars.length]);
const calendarEvents = filteredEvents.map((event) => ({
id: String(event.id),
title: event.title,
start: event.start_datetime,
end: event.end_datetime || undefined,
allDay: event.all_day,
backgroundColor: event.color || 'hsl(var(--accent-color))',
borderColor: event.color || 'hsl(var(--accent-color))',
backgroundColor: event.calendar_color || 'hsl(var(--accent-color))',
borderColor: event.calendar_color || 'hsl(var(--accent-color))',
extendedProps: {
is_virtual: event.is_virtual,
},
}));
const handleEventClick = (info: EventClickArg) => {
const event = events.find((e) => e.id.toString() === info.event.id);
if (event) {
const event = events.find((e) => String(e.id) === info.event.id);
if (!event) return;
if (event.is_virtual) {
toast.info(`${event.title} — from People contacts`);
return;
}
setEditingEvent(event);
setShowForm(true);
}
};
const handleEventDrop = (info: EventDropArg) => {
if (info.event.extendedProps.is_virtual) {
info.revert();
return;
}
const id = parseInt(info.event.id);
const start = info.event.allDay
? info.event.startStr
@ -116,6 +150,10 @@ export default function CalendarPage() {
};
const handleEventResize = (info: { event: EventDropArg['event']; revert: () => void }) => {
if (info.event.extendedProps.is_virtual) {
info.revert();
return;
}
const id = parseInt(info.event.id);
const start = info.event.allDay
? info.event.startStr
@ -146,23 +184,64 @@ export default function CalendarPage() {
setSelectedAllDay(false);
};
const handleDatesSet = (arg: DatesSetArg) => {
setCalendarTitle(arg.view.title);
setCurrentView(arg.view.type as CalendarView);
};
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 flex-col h-full">
<div className="border-b bg-card px-6 py-4">
<h1 className="text-3xl font-bold">Calendar</h1>
<div className="flex h-full overflow-hidden">
<CalendarSidebar />
<div className="flex-1 flex flex-col overflow-hidden">
{/* Custom toolbar */}
<div className="border-b bg-card px-6 py-3 flex items-center gap-4">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigateNext}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" className="h-8" onClick={navigateToday}>
Today
</Button>
<h2 className="text-lg font-semibold font-heading flex-1">{calendarTitle}</h2>
<div className="flex items-center rounded-md border border-border overflow-hidden">
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
<button
key={view}
onClick={() => changeView(view)}
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
currentView === view
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor: currentView === view ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: currentView === view ? 'hsl(var(--accent-color))' : undefined,
}}
>
{label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="bg-card rounded-lg border p-4">
{/* Calendar grid */}
<div className="flex-1 overflow-y-auto p-4">
<div className="bg-card rounded-lg border p-3 h-full">
<FullCalendar
ref={calendarRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay',
}}
headerToolbar={false}
events={calendarEvents}
editable={true}
selectable={true}
@ -170,14 +249,17 @@ export default function CalendarPage() {
unselectAuto={false}
dayMaxEvents={true}
weekends={true}
nowIndicator={true}
eventClick={handleEventClick}
eventDrop={handleEventDrop}
eventResize={handleEventResize}
select={handleDateSelect}
height="auto"
datesSet={handleDatesSet}
height="100%"
/>
</div>
</div>
</div>
{showForm && (
<EventForm

View File

@ -0,0 +1,99 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil } from 'lucide-react';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { Calendar } from '@/types';
import { useCalendars } from '@/hooks/useCalendars';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import CalendarForm from './CalendarForm';
export default function CalendarSidebar() {
const queryClient = useQueryClient();
const { data: calendars = [] } = useCalendars();
const [showForm, setShowForm] = useState(false);
const [editingCalendar, setEditingCalendar] = useState<Calendar | null>(null);
const toggleMutation = useMutation({
mutationFn: async ({ id, is_visible }: { id: number; is_visible: boolean }) => {
await api.put(`/calendars/${id}`, { is_visible });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendars'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to update calendar'));
},
});
const handleToggle = (calendar: Calendar) => {
toggleMutation.mutate({ id: calendar.id, is_visible: !calendar.is_visible });
};
const handleEdit = (calendar: Calendar) => {
setEditingCalendar(calendar);
setShowForm(true);
};
const handleCloseForm = () => {
setShowForm(false);
setEditingCalendar(null);
};
return (
<div className="w-56 shrink-0 border-r bg-card flex flex-col">
<div className="p-4 border-b flex items-center justify-between">
<span className="text-sm font-semibold font-heading text-foreground">Calendars</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => { setEditingCalendar(null); setShowForm(true); }}
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-0.5">
{calendars.map((cal) => (
<div
key={cal.id}
className="group flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150"
>
<Checkbox
checked={cal.is_visible}
onChange={() => handleToggle(cal)}
className="shrink-0"
style={{
accentColor: cal.color,
borderColor: cal.is_visible ? cal.color : undefined,
backgroundColor: cal.is_visible ? cal.color : undefined,
}}
/>
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: cal.color }}
/>
<span className="text-sm text-foreground truncate flex-1">{cal.name}</span>
{!cal.is_system && (
<button
onClick={() => handleEdit(cal)}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
{showForm && (
<CalendarForm
calendar={editingCalendar}
onClose={handleCloseForm}
/>
)}
</div>
);
}

View File

@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent, Location } from '@/types';
import { useCalendars } from '@/hooks/useCalendars';
import {
Dialog,
DialogContent,
@ -26,31 +27,17 @@ interface EventFormProps {
onClose: () => void;
}
const colorPresets = [
{ name: 'Accent', value: '' },
{ name: 'Red', value: '#ef4444' },
{ name: 'Orange', value: '#f97316' },
{ name: 'Yellow', value: '#eab308' },
{ name: 'Green', value: '#22c55e' },
{ name: 'Blue', value: '#3b82f6' },
{ name: 'Purple', value: '#8b5cf6' },
{ name: 'Pink', value: '#ec4899' },
];
// Extract just the date portion (YYYY-MM-DD) from any date/datetime string
function toDateOnly(dt: string): string {
if (!dt) return '';
return dt.split('T')[0];
}
// Ensure a datetime string is in datetime-local format (YYYY-MM-DDThh:mm)
function toDatetimeLocal(dt: string, fallbackTime: string = '09:00'): string {
if (!dt) return '';
if (dt.includes('T')) return dt.slice(0, 16); // trim seconds/timezone
if (dt.includes('T')) return dt.slice(0, 16);
return `${dt}T${fallbackTime}`;
}
// Format a date/datetime string for the correct input type
function formatForInput(dt: string, allDay: boolean, fallbackTime: string = '09:00'): string {
if (!dt) return '';
return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime);
@ -58,9 +45,14 @@ function formatForInput(dt: string, allDay: boolean, fallbackTime: string = '09:
export default function EventForm({ event, initialStart, initialEnd, initialAllDay, onClose }: EventFormProps) {
const queryClient = useQueryClient();
const { data: calendars = [] } = useCalendars();
const isAllDay = event?.all_day ?? initialAllDay ?? false;
const rawStart = event?.start_datetime || initialStart || '';
const rawEnd = event?.end_datetime || initialEnd || '';
const defaultCalendar = calendars.find((c) => c.is_default);
const initialCalendarId = event?.calendar_id?.toString() || defaultCalendar?.id?.toString() || '';
const [formData, setFormData] = useState({
title: event?.title || '',
description: event?.description || '',
@ -68,7 +60,7 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
end_datetime: formatForInput(rawEnd, isAllDay, '10:00'),
all_day: isAllDay,
location_id: event?.location_id?.toString() || '',
color: event?.color || '',
calendar_id: initialCalendarId,
recurrence_rule: event?.recurrence_rule || '',
is_starred: event?.is_starred || false,
});
@ -81,11 +73,15 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
},
});
// Filter out system calendars (Birthdays) from the dropdown
const selectableCalendars = calendars.filter((c) => !c.is_system);
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
const payload = {
...data,
location_id: data.location_id ? parseInt(data.location_id) : null,
calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null,
};
if (event) {
const response = await api.put(`/events/${event.id}`, payload);
@ -196,6 +192,21 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
</div>
</div>
<div className="space-y-2">
<Label htmlFor="calendar">Calendar</Label>
<Select
id="calendar"
value={formData.calendar_id}
onChange={(e) => setFormData({ ...formData, calendar_id: e.target.value })}
>
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>
{cal.name}
</option>
))}
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="location">Location</Label>
<Select
@ -212,21 +223,6 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Select
id="color"
value={formData.color}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
>
{colorPresets.map((preset) => (
<option key={preset.name} value={preset.value}>
{preset.name}
</option>
))}
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="recurrence">Recurrence</Label>
<Select

View File

@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Calendar } from '@/types';
export function useCalendars() {
return useQuery({
queryKey: ['calendars'],
queryFn: async () => {
const { data } = await api.get<Calendar[]>('/calendars');
return data;
},
});
}

View File

@ -97,6 +97,13 @@
--fc-neutral-bg-color: hsl(0 0% 5%);
--fc-neutral-text-color: hsl(0 0% 98%);
--fc-list-event-hover-bg-color: hsl(0 0% 10%);
--fc-today-bg-color: hsl(var(--accent-color) / 0.08);
--fc-now-indicator-color: hsl(var(--accent-color));
}
/* Hide default FC toolbar (we use a custom React toolbar) */
.fc .fc-header-toolbar {
display: none !important;
}
.fc .fc-button-primary {
@ -116,8 +123,24 @@
border-color: var(--fc-button-active-border-color);
}
/* Today highlight — accent tint */
.fc .fc-daygrid-day.fc-day-today {
background-color: hsl(0 0% 8%) !important;
background-color: hsl(var(--accent-color) / 0.08) !important;
}
.fc .fc-timegrid-col.fc-day-today {
background-color: hsl(var(--accent-color) / 0.06) !important;
}
/* Now indicator — accent colored line */
.fc .fc-timegrid-now-indicator-line {
border-color: hsl(var(--accent-color));
border-width: 2px;
}
.fc .fc-timegrid-now-indicator-arrow {
border-top-color: hsl(var(--accent-color));
border-bottom-color: hsl(var(--accent-color));
}
.fc .fc-col-header-cell {
@ -146,3 +169,25 @@
.fc .fc-timegrid-slot-label {
color: hsl(0 0% 63.9%);
}
/* Event pills — compact rounded style */
.fc .fc-daygrid-event {
border-radius: 4px;
font-size: 0.75rem;
padding: 1px 4px;
}
.fc .fc-timegrid-event {
border-radius: 4px;
font-size: 0.75rem;
}
.fc .fc-timegrid-event .fc-event-main {
padding: 2px 4px;
}
/* Day number styling for today */
.fc .fc-day-today .fc-daygrid-day-number {
color: hsl(var(--accent-color));
font-weight: 600;
}

View File

@ -33,8 +33,19 @@ export interface Todo {
updated_at: string;
}
export interface CalendarEvent {
export interface Calendar {
id: number;
name: string;
color: string;
is_default: boolean;
is_system: boolean;
is_visible: boolean;
created_at: string;
updated_at: string;
}
export interface CalendarEvent {
id: number | string;
title: string;
description?: string;
start_datetime: string;
@ -42,6 +53,10 @@ export interface CalendarEvent {
all_day: boolean;
location_id?: number;
color?: string;
calendar_id: number;
calendar_name: string;
calendar_color: string;
is_virtual?: boolean;
recurrence_rule?: string;
is_starred?: boolean;
created_at: string;