Add mini monthly calendar to sidebar for quick date navigation
New MiniCalendar component with independent month browsing, today/selected highlights, firstDayOfWeek support, and month sync with main calendar. Replaces old "Calendars" header with the mini-cal + "MY CALENDARS" heading. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1daec977ba
commit
a5ac047b0b
@ -51,7 +51,9 @@ export default function CalendarPage() {
|
|||||||
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
|
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
|
||||||
|
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
const firstDayOfWeek = settings?.first_day_of_week ?? 0;
|
||||||
const { data: calendars = [], sharedData, allCalendarIds } = useCalendars({ pollingEnabled: true });
|
const { data: calendars = [], sharedData, allCalendarIds } = useCalendars({ pollingEnabled: true });
|
||||||
|
const [currentDate, setCurrentDate] = useState<string>(() => format(new Date(), 'yyyy-MM-dd'));
|
||||||
const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set());
|
const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set());
|
||||||
const calendarContainerRef = useRef<HTMLDivElement>(null);
|
const calendarContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -107,6 +109,10 @@ export default function CalendarPage() {
|
|||||||
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarWidth));
|
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarWidth));
|
||||||
}, [sidebarWidth]);
|
}, [sidebarWidth]);
|
||||||
|
|
||||||
|
const handleMiniCalClick = useCallback((dateStr: string) => {
|
||||||
|
calendarRef.current?.getApi().gotoDate(dateStr);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Location data for event panel
|
// Location data for event panel
|
||||||
const { data: locations = [] } = useQuery({
|
const { data: locations = [] } = useQuery({
|
||||||
queryKey: ['locations'],
|
queryKey: ['locations'],
|
||||||
@ -513,6 +519,8 @@ export default function CalendarPage() {
|
|||||||
setVisibleRange((prev) =>
|
setVisibleRange((prev) =>
|
||||||
prev.start === start && prev.end === end ? prev : { start, end }
|
prev.start === start && prev.end === end ? prev : { start, end }
|
||||||
);
|
);
|
||||||
|
// Track current date anchor for mini calendar sync
|
||||||
|
setCurrentDate(format(arg.view.currentStart, 'yyyy-MM-dd'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigatePrev = () => calendarRef.current?.getApi().prev();
|
const navigatePrev = () => calendarRef.current?.getApi().prev();
|
||||||
@ -591,7 +599,7 @@ export default function CalendarPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full overflow-hidden animate-fade-in">
|
<div className="flex h-full overflow-hidden animate-fade-in">
|
||||||
<div className="hidden lg:flex lg:flex-row shrink-0">
|
<div className="hidden lg:flex lg:flex-row shrink-0">
|
||||||
<CalendarSidebar ref={sidebarRef} onUseTemplate={handleUseTemplate} onSharedVisibilityChange={setVisibleSharedIds} width={sidebarWidth} />
|
<CalendarSidebar ref={sidebarRef} onUseTemplate={handleUseTemplate} onSharedVisibilityChange={setVisibleSharedIds} width={sidebarWidth} onDateClick={handleMiniCalClick} currentDate={currentDate} firstDayOfWeek={firstDayOfWeek} />
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleSidebarMouseDown}
|
onMouseDown={handleSidebarMouseDown}
|
||||||
className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors duration-150"
|
className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors duration-150"
|
||||||
@ -602,7 +610,7 @@ export default function CalendarPage() {
|
|||||||
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
|
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
|
||||||
<SheetContent className="w-72 p-0">
|
<SheetContent className="w-72 p-0">
|
||||||
<SheetClose onClick={() => setMobileSidebarOpen(false)} />
|
<SheetClose onClick={() => setMobileSidebarOpen(false)} />
|
||||||
<CalendarSidebar onUseTemplate={(tmpl) => { setMobileSidebarOpen(false); handleUseTemplate(tmpl); }} onSharedVisibilityChange={setVisibleSharedIds} width={288} />
|
<CalendarSidebar onUseTemplate={(tmpl) => { setMobileSidebarOpen(false); handleUseTemplate(tmpl); }} onSharedVisibilityChange={setVisibleSharedIds} width={288} onDateClick={handleMiniCalClick} currentDate={currentDate} firstDayOfWeek={firstDayOfWeek} />
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)}
|
)}
|
||||||
@ -712,12 +720,12 @@ export default function CalendarPage() {
|
|||||||
>
|
>
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<FullCalendar
|
<FullCalendar
|
||||||
key={`fc-${settings?.first_day_of_week ?? 0}`}
|
key={`fc-${firstDayOfWeek}`}
|
||||||
ref={calendarRef}
|
ref={calendarRef}
|
||||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||||
initialView="dayGridMonth"
|
initialView="dayGridMonth"
|
||||||
headerToolbar={false}
|
headerToolbar={false}
|
||||||
firstDay={settings?.first_day_of_week ?? 0}
|
firstDay={firstDayOfWeek}
|
||||||
events={calendarEvents}
|
events={calendarEvents}
|
||||||
editable={true}
|
editable={true}
|
||||||
selectable={true}
|
selectable={true}
|
||||||
|
|||||||
@ -10,14 +10,18 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import CalendarForm from './CalendarForm';
|
import CalendarForm from './CalendarForm';
|
||||||
import TemplateForm from './TemplateForm';
|
import TemplateForm from './TemplateForm';
|
||||||
import SharedCalendarSection, { loadVisibility, saveVisibility } from './SharedCalendarSection';
|
import SharedCalendarSection, { loadVisibility, saveVisibility } from './SharedCalendarSection';
|
||||||
|
import MiniCalendar from './MiniCalendar';
|
||||||
|
|
||||||
interface CalendarSidebarProps {
|
interface CalendarSidebarProps {
|
||||||
onUseTemplate?: (template: EventTemplate) => void;
|
onUseTemplate?: (template: EventTemplate) => void;
|
||||||
onSharedVisibilityChange?: (visibleIds: Set<number>) => void;
|
onSharedVisibilityChange?: (visibleIds: Set<number>) => void;
|
||||||
width: number;
|
width: number;
|
||||||
|
onDateClick?: (dateStr: string) => void;
|
||||||
|
currentDate?: string;
|
||||||
|
firstDayOfWeek?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(function CalendarSidebar({ onUseTemplate, onSharedVisibilityChange, width }, ref) {
|
const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(function CalendarSidebar({ onUseTemplate, onSharedVisibilityChange, width, onDateClick, currentDate, firstDayOfWeek }, ref) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: calendars = [], sharedData: sharedCalendars = [] } = useCalendars();
|
const { data: calendars = [], sharedData: sharedCalendars = [] } = useCalendars();
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@ -95,20 +99,32 @@ const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(functio
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="shrink-0 border-r bg-card flex flex-col" style={{ width }}>
|
<div ref={ref} className="shrink-0 border-r bg-card flex flex-col" style={{ width }}>
|
||||||
<div className="h-16 px-4 border-b flex items-center justify-between shrink-0">
|
{onDateClick && (
|
||||||
<span className="text-sm font-semibold font-heading text-foreground">Calendars</span>
|
<div className="border-b shrink-0">
|
||||||
<Button
|
<MiniCalendar
|
||||||
variant="ghost"
|
onDateClick={onDateClick}
|
||||||
size="icon"
|
currentDate={currentDate}
|
||||||
className="h-7 w-7"
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
onClick={() => { setEditingCalendar(null); setShowForm(true); }}
|
/>
|
||||||
>
|
</div>
|
||||||
<Plus className="h-4 w-4" />
|
)}
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||||
{/* Owned calendars list (non-shared only) */}
|
{/* Owned calendars list (non-shared only) */}
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
My Calendars
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={() => { setEditingCalendar(null); setShowForm(true); }}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
{calendars.filter((c) => !c.is_shared).map((cal) => (
|
{calendars.filter((c) => !c.is_shared).map((cal) => (
|
||||||
<div
|
<div
|
||||||
key={cal.id}
|
key={cal.id}
|
||||||
@ -138,6 +154,7 @@ const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(functio
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Shared calendars section -- owned + member */}
|
{/* Shared calendars section -- owned + member */}
|
||||||
{(calendars.some((c) => c.is_shared) || sharedCalendars.length > 0) && (
|
{(calendars.some((c) => c.is_shared) || sharedCalendars.length > 0) && (
|
||||||
|
|||||||
153
frontend/src/components/calendar/MiniCalendar.tsx
Normal file
153
frontend/src/components/calendar/MiniCalendar.tsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { useState, useEffect, useMemo, memo } from 'react';
|
||||||
|
import {
|
||||||
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||||
|
eachDayOfInterval, format, isSameDay, isSameMonth, isToday,
|
||||||
|
addMonths, subMonths,
|
||||||
|
} from 'date-fns';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface MiniCalendarProps {
|
||||||
|
onDateClick: (dateStr: string) => void;
|
||||||
|
currentDate?: string;
|
||||||
|
firstDayOfWeek?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||||
|
|
||||||
|
function buildGrid(displayed: Date, firstDay: number) {
|
||||||
|
const monthStart = startOfMonth(displayed);
|
||||||
|
const monthEnd = endOfMonth(displayed);
|
||||||
|
const gridStart = startOfWeek(monthStart, { weekStartsOn: firstDay as 0 | 1 | 2 | 3 | 4 | 5 | 6 });
|
||||||
|
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: firstDay as 0 | 1 | 2 | 3 | 4 | 5 | 6 });
|
||||||
|
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrderedLabels(firstDay: number) {
|
||||||
|
return [...DAY_LABELS.slice(firstDay), ...DAY_LABELS.slice(0, firstDay)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MiniCalendar = memo(function MiniCalendar({
|
||||||
|
onDateClick,
|
||||||
|
currentDate,
|
||||||
|
firstDayOfWeek = 1,
|
||||||
|
}: MiniCalendarProps) {
|
||||||
|
const [displayedMonth, setDisplayedMonth] = useState(() =>
|
||||||
|
currentDate ? startOfMonth(new Date(currentDate)) : startOfMonth(new Date())
|
||||||
|
);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | null>(
|
||||||
|
currentDate ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync displayed month when main calendar navigates across months
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentDate) return;
|
||||||
|
const incoming = startOfMonth(new Date(currentDate));
|
||||||
|
setDisplayedMonth((prev) =>
|
||||||
|
prev.getTime() === incoming.getTime() ? prev : incoming
|
||||||
|
);
|
||||||
|
setSelectedDate(currentDate);
|
||||||
|
}, [currentDate]);
|
||||||
|
|
||||||
|
const days = useMemo(
|
||||||
|
() => buildGrid(displayedMonth, firstDayOfWeek),
|
||||||
|
[displayedMonth, firstDayOfWeek]
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderedLabels = useMemo(
|
||||||
|
() => getOrderedLabels(firstDayOfWeek),
|
||||||
|
[firstDayOfWeek]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePrev = () => setDisplayedMonth((m) => subMonths(m, 1));
|
||||||
|
const handleNext = () => setDisplayedMonth((m) => addMonths(m, 1));
|
||||||
|
|
||||||
|
const handleDayClick = (day: Date) => {
|
||||||
|
const dateStr = format(day, 'yyyy-MM-dd');
|
||||||
|
setSelectedDate(dateStr);
|
||||||
|
// If clicking a day in another month, also shift the displayed month
|
||||||
|
if (!isSameMonth(day, displayedMonth)) {
|
||||||
|
setDisplayedMonth(startOfMonth(day));
|
||||||
|
}
|
||||||
|
onDateClick(dateStr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedDateObj = selectedDate ? new Date(selectedDate) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-3 pt-3 pb-2 max-w-[280px] mx-auto">
|
||||||
|
{/* Month header with navigation */}
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={handlePrev}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm font-medium font-heading select-none">
|
||||||
|
{format(displayedMonth, 'MMMM yyyy')}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={handleNext}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day-of-week headers */}
|
||||||
|
<div className="grid grid-cols-7 mb-0.5">
|
||||||
|
{orderedLabels.map((label) => (
|
||||||
|
<span
|
||||||
|
key={label}
|
||||||
|
className="text-[10px] text-muted-foreground text-center select-none"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day grid */}
|
||||||
|
<div className="grid grid-cols-7">
|
||||||
|
{days.map((day) => {
|
||||||
|
const isCurrentMonth = isSameMonth(day, displayedMonth);
|
||||||
|
const today = isToday(day);
|
||||||
|
const isSelected = selectedDateObj ? isSameDay(day, selectedDateObj) : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.toISOString()}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDayClick(day)}
|
||||||
|
className={[
|
||||||
|
'h-7 text-xs flex items-center justify-center rounded-md transition-colors duration-100',
|
||||||
|
isSelected
|
||||||
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
|
: today
|
||||||
|
? 'bg-accent/20 text-accent font-semibold'
|
||||||
|
: isCurrentMonth
|
||||||
|
? 'text-foreground hover:bg-accent/10'
|
||||||
|
: 'text-muted-foreground/40 hover:bg-accent/10',
|
||||||
|
].join(' ')}
|
||||||
|
style={
|
||||||
|
isSelected
|
||||||
|
? { backgroundColor: 'hsl(var(--accent-color))', color: 'hsl(var(--accent-foreground))' }
|
||||||
|
: today && !isSelected
|
||||||
|
? { backgroundColor: 'hsl(var(--accent-color) / 0.2)', color: 'hsl(var(--accent-color))' }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{format(day, 'd')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default MiniCalendar;
|
||||||
Loading…
x
Reference in New Issue
Block a user