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>
225 lines
8.5 KiB
TypeScript
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; |