Compare commits
7 Commits
1daec977ba
...
c5a309f4a1
| Author | SHA1 | Date | |
|---|---|---|---|
| c5a309f4a1 | |||
| 0ba920f8e1 | |||
| 68337b12a0 | |||
| bda02039a6 | |||
| 2d76ecf869 | |||
| b939843249 | |||
| a5ac047b0b |
@ -51,7 +51,10 @@ 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 [navKey, setNavKey] = useState(0);
|
||||||
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 +110,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 +520,10 @@ 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'));
|
||||||
|
// Increment nav key so mini calendar clears selection even when month doesn't change
|
||||||
|
setNavKey((k) => k + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigatePrev = () => calendarRef.current?.getApi().prev();
|
const navigatePrev = () => calendarRef.current?.getApi().prev();
|
||||||
@ -591,7 +602,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} navKey={navKey} />
|
||||||
<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 +613,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={(dateStr) => { setMobileSidebarOpen(false); handleMiniCalClick(dateStr); }} currentDate={currentDate} firstDayOfWeek={firstDayOfWeek} navKey={navKey} />
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)}
|
)}
|
||||||
@ -712,12 +723,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,19 @@ 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;
|
||||||
|
navKey?: 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, navKey }, 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 +100,36 @@ 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">
|
<div className="h-16 px-4 border-b flex items-center shrink-0">
|
||||||
<span className="text-sm font-semibold font-heading text-foreground">Calendars</span>
|
<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>
|
||||||
|
{onDateClick && (
|
||||||
|
<div className="border-b shrink-0">
|
||||||
|
<MiniCalendar
|
||||||
|
onDateClick={onDateClick}
|
||||||
|
currentDate={currentDate}
|
||||||
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
|
navKey={navKey}
|
||||||
|
/>
|
||||||
|
</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 +159,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) && (
|
||||||
|
|||||||
166
frontend/src/components/calendar/MiniCalendar.tsx
Normal file
166
frontend/src/components/calendar/MiniCalendar.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { useState, useEffect, useMemo, useCallback, memo } from 'react';
|
||||||
|
import {
|
||||||
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||||
|
eachDayOfInterval, format, isSameDay, isSameMonth, isToday,
|
||||||
|
addMonths, subMonths, parse,
|
||||||
|
} 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;
|
||||||
|
navKey?: 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 = 0,
|
||||||
|
navKey,
|
||||||
|
}: MiniCalendarProps) {
|
||||||
|
const REF_DATE = useMemo(() => new Date(), []);
|
||||||
|
|
||||||
|
const parseDate = useCallback(
|
||||||
|
(dateStr: string) => parse(dateStr, 'yyyy-MM-dd', REF_DATE),
|
||||||
|
[REF_DATE]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [displayedMonth, setDisplayedMonth] = useState(() =>
|
||||||
|
currentDate ? startOfMonth(parseDate(currentDate)) : startOfMonth(new Date())
|
||||||
|
);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Sync displayed month when main calendar navigates across months
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentDate) return;
|
||||||
|
const incoming = startOfMonth(parseDate(currentDate));
|
||||||
|
setDisplayedMonth((prev) =>
|
||||||
|
prev.getTime() === incoming.getTime() ? prev : incoming
|
||||||
|
);
|
||||||
|
}, [currentDate, parseDate]);
|
||||||
|
|
||||||
|
// Clear selection on any toolbar navigation (today/prev/next)
|
||||||
|
// navKey increments on every datesSet, even when the month doesn't change
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedDate(null);
|
||||||
|
}, [navKey]);
|
||||||
|
|
||||||
|
const days = useMemo(
|
||||||
|
() => buildGrid(displayedMonth, firstDayOfWeek),
|
||||||
|
[displayedMonth, firstDayOfWeek]
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderedLabels = useMemo(
|
||||||
|
() => getOrderedLabels(firstDayOfWeek),
|
||||||
|
[firstDayOfWeek]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePrev = useCallback(() => setDisplayedMonth((m) => subMonths(m, 1)), []);
|
||||||
|
const handleNext = useCallback(() => setDisplayedMonth((m) => addMonths(m, 1)), []);
|
||||||
|
|
||||||
|
const handleDayClick = useCallback((day: Date) => {
|
||||||
|
const dateStr = format(day, 'yyyy-MM-dd');
|
||||||
|
setSelectedDate(dateStr);
|
||||||
|
setDisplayedMonth((prev) => isSameMonth(day, prev) ? prev : startOfMonth(day));
|
||||||
|
onDateClick(dateStr);
|
||||||
|
}, [onDateClick]);
|
||||||
|
|
||||||
|
const selectedDateObj = useMemo(
|
||||||
|
() => selectedDate ? parseDate(selectedDate) : null,
|
||||||
|
[selectedDate, parseDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
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={format(day, 'yyyy-MM-dd')}
|
||||||
|
type="button"
|
||||||
|
aria-label={format(day, 'EEEE, MMMM d, yyyy')}
|
||||||
|
onClick={() => handleDayClick(day)}
|
||||||
|
className={[
|
||||||
|
'h-7 text-xs flex items-center justify-center rounded-md transition-colors duration-100',
|
||||||
|
isSelected
|
||||||
|
? 'font-medium'
|
||||||
|
: today
|
||||||
|
? '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