feat: P2.3 Folder Sharing + P2.5 Email Import + P2.6 Contact Import + P2.7 Free/Busy

- P2.3: Folder sharing system — ShareFolderDialog, sharing-store, sharing-settings
- P2.5: Email import (.eml, .tgz, .zip) with dedup and progress
- P2.6: Contact import (vCard + CSV) with auto-mapping
- P2.7: Free/Busy view grid with color-coded slots
This commit is contained in:
Bernd Rodler
2026-08-07 13:32:33 +02:00
parent 83e29b3ef1
commit e7acf56753
21 changed files with 3321 additions and 43 deletions
+41 -1
View File
@@ -4,13 +4,14 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations, useLocale } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus, Eye, EyeOff } from "lucide-react";
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
import { FreeBusyView } from "./free-busy-view";
import {
isOrganizer,
getUserParticipantId,
@@ -351,6 +352,7 @@ export function EventModal({
.map(p => ({ name: p.name, email: p.email }));
});
const [sendInvitations, setSendInvitations] = useState(true);
const [showFreeBusy, setShowFreeBusy] = useState(false);
const participantInputRef = useRef<ParticipantInputHandle>(null);
// Plugin transform: collect conflict warnings for the current event form.
@@ -1074,6 +1076,44 @@ export function EventModal({
onAdd={handleAddAttendee}
onRemove={handleRemoveAttendee}
/>
{attendees.length > 0 && !allDay && (
<div className="mt-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowFreeBusy((prev) => !prev)}
className="text-xs"
>
{showFreeBusy ? (
<EyeOff className="w-3.5 h-3.5 me-1" />
) : (
<Eye className="w-3.5 h-3.5 me-1" />
)}
{showFreeBusy ? t("freeBusy.hide") : t("freeBusy.check")}
</Button>
{showFreeBusy && (
<div className="mt-3">
<FreeBusyView
participants={attendees}
startDate={(() => {
const d = new Date(`${startDate}T${startTime}:00`);
return isNaN(d.getTime()) ? new Date() : d;
})()}
endDate={(() => {
const d = new Date(`${endDate}T${endTime}:00`);
return isNaN(d.getTime()) ? addHours(new Date(`${startDate}T${startTime}:00`), 8) : d;
})()}
onTimeSelect={(start, end) => {
setStartDate(formatDateInput(start));
setStartTime(formatTimeInput(start));
setEndDate(formatDateInput(end));
setEndTime(formatTimeInput(end));
}}
/>
</div>
)}
</div>
)}
{isEdit && statusCounts && (existingParticipants.length > 0) && (
<p className="text-xs text-muted-foreground mt-1.5">
{t("participants.status_summary", {
+296
View File
@@ -0,0 +1,296 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useTranslations } from "next-intl";
import { addMinutes, differenceInMinutes, format } from "date-fns";
import { Avatar } from "@/components/ui/avatar";
import { useAuthStore } from "@/stores/auth-store";
import { cn } from "@/lib/utils";
import { fetchFreeBusy, type FreeBusySlot, isWorkingHour as isWorkingHourFn } from "@/lib/calendar-freebusy";
export interface FreeBusyViewProps {
participants: { name?: string; email: string }[];
startDate: Date;
endDate: Date;
onTimeSelect?: (start: Date, end: Date) => void;
}
const SLOT_MINUTES = 30;
const WORK_START_HOUR = 8;
const WORK_END_HOUR = 18;
const statusColors: Record<FreeBusySlot["status"], string> = {
free: "bg-emerald-100 dark:bg-emerald-900/40 border-emerald-200 dark:border-emerald-800",
busy: "bg-red-100 dark:bg-red-900/40 border-red-200 dark:border-red-800",
tentative: "bg-amber-100 dark:bg-amber-900/40 border-amber-200 dark:border-amber-800",
unavailable: "bg-purple-100 dark:bg-purple-900/40 border-purple-200 dark:border-purple-800",
unknown: "bg-muted border-muted-foreground/20",
};
const statusHoverColors: Record<FreeBusySlot["status"], string> = {
free: "hover:bg-emerald-200 dark:hover:bg-emerald-800/60",
busy: "hover:bg-red-200 dark:hover:bg-red-800/60",
tentative: "hover:bg-amber-200 dark:hover:bg-amber-800/60",
unavailable: "hover:bg-purple-200 dark:hover:bg-purple-800/60",
unknown: "hover:bg-muted-foreground/20",
};
function clampToSlot(d: Date): Date {
const clone = new Date(d);
clone.setSeconds(0, 0);
const mins = clone.getMinutes();
const remainder = mins % SLOT_MINUTES;
if (remainder !== 0) {
clone.setMinutes(mins - remainder, 0, 0);
}
return clone;
}
function buildHourSlots(start: Date, end: Date): { label: string; slots: FreeBusySlot[] }[] {
const hours: { label: string; slots: FreeBusySlot[] }[] = [];
let cursor = clampToSlot(start);
while (cursor < end) {
const hourEnd = new Date(cursor);
hourEnd.setHours(hourEnd.getHours() + 1, 0, 0, 0);
const hourSlots: FreeBusySlot[] = [];
let slotCursor = new Date(cursor);
while (slotCursor < hourEnd && slotCursor < end) {
const slotEnd = addMinutes(slotCursor, SLOT_MINUTES);
hourSlots.push({
start: new Date(slotCursor),
end: slotEnd > end ? new Date(end) : slotEnd,
status: "unknown",
});
slotCursor = slotEnd;
}
hours.push({ label: format(cursor, "HH:mm"), slots: hourSlots });
cursor = hourEnd;
}
return hours;
}
function isWorkingHour(hour: number): boolean {
return isWorkingHourFn(hour, WORK_START_HOUR, WORK_END_HOUR);
}
export function FreeBusyView({
participants,
startDate,
endDate,
onTimeSelect,
}: FreeBusyViewProps) {
const t = useTranslations("calendar");
const client = useAuthStore((s) => s.client);
const [freeBusyData, setFreeBusyData] = useState<Map<string, FreeBusySlot[]> | null>(null);
const [loading, setLoading] = useState(false);
const [hoveredSlot, setHoveredSlot] = useState<{
participant: string;
slotIndex: number;
} | null>(null);
const hourSlots = useMemo(() => buildHourSlots(startDate, endDate), [startDate, endDate]);
const totalHalfHourSlots = useMemo(() => {
let c = 0;
for (const h of hourSlots) c += h.slots.length;
return c;
}, [hourSlots]);
const now = new Date();
const showNowLine =
now >= startDate && now <= endDate;
const nowPositionPercent = showNowLine
? Math.max(0, Math.min(100, (differenceInMinutes(now, startDate) / differenceInMinutes(endDate, startDate)) * 100))
: null;
useEffect(() => {
if (!client || participants.length === 0) return;
let cancelled = false;
setLoading(true);
fetchFreeBusy(client, participants, startDate, endDate)
.then((data) => {
if (!cancelled) {
setFreeBusyData(data);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [client, participants, startDate, endDate]);
const handleSlotClick = useCallback(
(slot: FreeBusySlot) => {
if (slot.status === "free" && onTimeSelect) {
onTimeSelect(new Date(slot.start), new Date(slot.end));
}
},
[onTimeSelect]
);
const timezone = useMemo(
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
[]
);
if (participants.length === 0) {
return (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("freeBusy.no_participants")}
</p>
);
}
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">
{t("freeBusy.timezone")}: {timezone}
</div>
{loading && (
<div className="text-xs text-muted-foreground animate-pulse">
{t("freeBusy.loading")}
</div>
)}
</div>
<div className="overflow-auto border border-border rounded-lg">
<div className="min-w-max" style={{ minWidth: totalHalfHourSlots * 24 + 200 }}>
<table className="w-full border-collapse text-xs">
<thead>
<tr>
<th className="sticky left-0 z-10 bg-background border-b border-r border-border px-3 py-2 text-left w-[180px] min-w-[180px]">
{t("participants.title")}
</th>
{hourSlots.map((hour, i) => (
<th
key={i}
colSpan={hour.slots.length}
className={cn(
"border-b border-r border-border px-1 py-2 text-center font-medium",
isWorkingHour(new Date(hour.slots[0]?.start).getHours())
? "bg-muted/50"
: "bg-muted/20"
)}
>
{hour.label}
</th>
))}
</tr>
</thead>
<tbody>
{participants.map((p) => {
const key = p.email.toLowerCase();
const slots = freeBusyData?.get(key);
return (
<tr key={key} className="border-b border-border">
<td className="sticky left-0 z-10 bg-background border-r border-border px-3 py-2">
<div className="flex items-center gap-2">
<Avatar
name={p.name}
email={p.email}
size="sm"
className="shrink-0"
/>
<div className="min-w-0">
<div className="font-medium truncate">
{p.name || p.email}
</div>
{p.name && (
<div className="text-[10px] text-muted-foreground truncate">
{p.email}
</div>
)}
</div>
</div>
</td>
{hourSlots.map((hour) =>
hour.slots.map((hourSlot, si) => {
const globalSlotIndex =
hourSlots
.slice(0, hourSlots.indexOf(hour))
.reduce((acc, h) => acc + h.slots.length, 0) + si;
const slot = slots?.[globalSlotIndex];
const status = slot?.status ?? "unknown";
const isFree = status === "free";
const isHovered =
hoveredSlot?.participant === key &&
hoveredSlot?.slotIndex === globalSlotIndex;
return (
<td
key={si}
className={cn(
"border-r border-border py-1 text-center relative cursor-default transition-colors",
statusColors[status],
isFree && statusHoverColors[status],
isFree && "cursor-pointer",
isHovered && "ring-1 ring-inset ring-primary/50",
isWorkingHour(new Date(hourSlot.start).getHours())
? ""
: "opacity-70"
)}
title={format(hourSlot.start, "HH:mm")}
onClick={() =>
isFree ? handleSlotClick(slot!) : undefined
}
onMouseEnter={() =>
setHoveredSlot({
participant: key,
slotIndex: globalSlotIndex,
})
}
onMouseLeave={() => setHoveredSlot(null)}
>
{status === "free" && (
<span className="block w-full h-full">&nbsp;</span>
)}
</td>
);
})
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{showNowLine && nowPositionPercent !== null && (
<div
className="absolute pointer-events-none z-20"
style={{
left: `calc(180px + ${nowPositionPercent}% * (1 - 180px / ${totalHalfHourSlots * 24 + 200}))`,
}}
/>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<span className="inline-flex items-center gap-1">
<span className="w-3 h-3 rounded border border-emerald-200 dark:border-emerald-800 bg-emerald-100 dark:bg-emerald-900/40" />
{t("freeBusy.free")}
</span>
<span className="inline-flex items-center gap-1">
<span className="w-3 h-3 rounded border border-red-200 dark:border-red-800 bg-red-100 dark:bg-red-900/40" />
{t("freeBusy.busy")}
</span>
<span className="inline-flex items-center gap-1">
<span className="w-3 h-3 rounded border border-amber-200 dark:border-amber-800 bg-amber-100 dark:bg-amber-900/40" />
{t("freeBusy.tentative")}
</span>
<span className="inline-flex items-center gap-1">
<span className="w-3 h-3 rounded border border-purple-200 dark:border-purple-800 bg-purple-100 dark:bg-purple-900/40" />
{t("freeBusy.unavailable")}
</span>
<span className="inline-flex items-center gap-1">
<span className="w-3 h-3 rounded border border-muted-foreground/20 bg-muted" />
{t("freeBusy.unknown")}
</span>
</div>
</div>
);
}