Files
SRCmail/components/calendar/recurrence-scope-dialog.tsx
Matthieu MALVACHEandMatthieu MALVACHE ccbab654f3 feat(calendar): add drag-create, resize, recurring scope, quick-create, and duplication
- Click-drag on empty time slots to create events with pre-filled time range
- Resize events by dragging bottom edge (15-min snap, optimistic JMAP update)
- Recurring event edit/delete scope dialog (this/following/all occurrences)
- Double-click quick event creation with inline title input
- Event duplication button in modal (+1 day offset)
- Shared interaction hook for pointer-based calendar interactions

Fixes #13
2026-02-22 17:32:10 +01:00

114 lines
3.7 KiB
TypeScript

"use client";
import { useState, useId } from "react";
import { useTranslations } from "next-intl";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { Button } from "@/components/ui/button";
import { Repeat, Trash2 } from "lucide-react";
export type RecurrenceEditScope = "this" | "this_and_future" | "all";
interface RecurrenceScopeDialogProps {
isOpen: boolean;
actionType: "edit" | "delete";
onSelect: (scope: RecurrenceEditScope) => void;
onClose: () => void;
}
export function RecurrenceScopeDialog({
isOpen,
actionType,
onSelect,
onClose,
}: RecurrenceScopeDialogProps) {
const t = useTranslations("calendar.recurrence_scope");
const id = useId();
const [selected, setSelected] = useState<RecurrenceEditScope>("this");
const dialogRef = useFocusTrap({
isActive: isOpen,
onEscape: onClose,
restoreFocus: true,
});
if (!isOpen) return null;
const isDelete = actionType === "delete";
const options: { value: RecurrenceEditScope; label: string }[] = [
{ value: "this", label: t("this_event") },
{ value: "this_and_future", label: t("this_and_future") },
{ value: "all", label: t("all_events") },
];
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[2px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-desc`}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-sm animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<div className="flex items-start gap-3 mb-4">
<div className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center ${
isDelete ? "bg-destructive/10" : "bg-primary/10"
}`}>
{isDelete ? (
<Trash2 className="w-5 h-5 text-destructive" />
) : (
<Repeat className="w-5 h-5 text-primary" />
)}
</div>
<div>
<h2 id={`${id}-title`} className="text-lg font-semibold">
{isDelete ? t("delete_title") : t("edit_title")}
</h2>
<p id={`${id}-desc`} className="text-sm text-muted-foreground mt-1">
{t("description")}
</p>
</div>
</div>
<div className="space-y-2" role="radiogroup" aria-labelledby={`${id}-title`}>
{options.map((option) => (
<label
key={option.value}
className={`flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer transition-colors ${
selected === option.value
? "bg-primary/10 border border-primary/30"
: "hover:bg-muted border border-transparent"
}`}
>
<input
type="radio"
name={`${id}-scope`}
value={option.value}
checked={selected === option.value}
onChange={() => setSelected(option.value)}
className="accent-primary"
/>
<span className="text-sm">{option.label}</span>
</label>
))}
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={onClose}>
{t("cancel")}
</Button>
<Button
variant={isDelete ? "destructive" : "default"}
onClick={() => onSelect(selected)}
>
{isDelete ? t("delete") : t("save")}
</Button>
</div>
</div>
</div>
);
}