UMBRA/frontend/src/components/calendar/CalendarSidebar.tsx
Kyle Pope f7ec04241b Phase 4: mobile polish and touch fallbacks
4a. Touch fallbacks for group-hover actions:
  - 9 occurrences across 5 files changed from opacity-0 group-hover:opacity-100
    to opacity-100 md:opacity-0 md:group-hover:opacity-100
  - CalendarSidebar (3), SharedCalendarSection (2), TaskDetailPanel (2),
    NotificationsPage (1), CopyableField (1)
  - Action buttons now always visible on touch, hover-revealed on desktop

4b. FullCalendar mobile touch:
  - Wheel navigation disabled on touch devices (ontouchstart check)
  - Prevents scroll hijacking on mobile, allows native scroll

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:04:44 +08:00

225 lines
8.5 KiB
TypeScript

import { useState, useEffect, useCallback, forwardRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, FileText } from 'lucide-react';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { Calendar, EventTemplate } from '@/types';
import { useCalendars } from '@/hooks/useCalendars';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import CalendarForm from './CalendarForm';
import TemplateForm from './TemplateForm';
import SharedCalendarSection, { loadVisibility, saveVisibility } from './SharedCalendarSection';
interface CalendarSidebarProps {
onUseTemplate?: (template: EventTemplate) => void;
onSharedVisibilityChange?: (visibleIds: Set<number>) => void;
width: number;
}
const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(function CalendarSidebar({ onUseTemplate, onSharedVisibilityChange, width }, ref) {
const queryClient = useQueryClient();
const { data: calendars = [], sharedData: sharedCalendars = [] } = useCalendars();
const [showForm, setShowForm] = useState(false);
const [editingCalendar, setEditingCalendar] = useState<Calendar | null>(null);
const [showTemplateForm, setShowTemplateForm] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<EventTemplate | null>(null);
const [sharedVisibility, setSharedVisibility] = useState<Record<number, boolean>>(() => loadVisibility());
const visibleSharedIds = new Set(
sharedCalendars
.filter((m) => sharedVisibility[m.calendar_id] !== false)
.map((m) => m.calendar_id)
);
useEffect(() => {
onSharedVisibilityChange?.(visibleSharedIds);
}, [sharedCalendars, sharedVisibility]);
const handleSharedVisibilityChange = useCallback((calendarId: number, visible: boolean) => {
setSharedVisibility((prev) => {
const next = { ...prev, [calendarId]: visible };
saveVisibility(next);
return next;
});
}, []);
const { data: templates = [] } = useQuery({
queryKey: ['event-templates'],
queryFn: async () => {
const { data } = await api.get<EventTemplate[]>('/event-templates');
return data;
},
});
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 deleteTemplateMutation = useMutation({
mutationFn: async (id: number) => {
await api.delete(`/event-templates/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-templates'] });
toast.success('Template deleted');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete template'));
},
});
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 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">
<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-4">
{/* Owned calendars list (non-shared only) */}
<div className="space-y-0.5">
{calendars.filter((c) => !c.is_shared).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>
<button
onClick={() => handleEdit(cal)}
className="opacity-100 md:opacity-0 md: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>
{/* Shared calendars section -- owned + member */}
{(calendars.some((c) => c.is_shared) || sharedCalendars.length > 0) && (
<SharedCalendarSection
ownedSharedCalendars={calendars.filter((c) => c.is_shared)}
memberships={sharedCalendars}
visibleSharedIds={visibleSharedIds}
onVisibilityChange={handleSharedVisibilityChange}
onEditCalendar={handleEdit}
onToggleCalendar={handleToggle}
/>
)}
{/* Templates section */}
<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">
Templates
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => { setEditingTemplate(null); setShowTemplateForm(true); }}
>
<Plus className="h-3 w-3" />
</Button>
</div>
{templates.length === 0 ? (
<p className="text-xs text-muted-foreground px-2">No templates yet</p>
) : (
<div className="space-y-0.5">
{templates.map((tmpl) => (
<div
key={tmpl.id}
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150 cursor-pointer"
onClick={() => onUseTemplate?.(tmpl)}
>
<FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm text-foreground truncate flex-1">{tmpl.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
setEditingTemplate(tmpl);
setShowTemplateForm(true);
}}
className="opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (!window.confirm(`Delete template "${tmpl.name}"?`)) return;
deleteTemplateMutation.mutate(tmpl.id);
}}
className="opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
</div>
{showForm && (
<CalendarForm
calendar={editingCalendar}
onClose={handleCloseForm}
/>
)}
{showTemplateForm && (
<TemplateForm
template={editingTemplate}
onClose={() => { setShowTemplateForm(false); setEditingTemplate(null); }}
/>
)}
</div>
);
});
export default CalendarSidebar;