e665ea4a79
Co-authored-by: Camille Moussu <cmoussu@linagora.com>
219 lines
6.4 KiB
TypeScript
219 lines
6.4 KiB
TypeScript
import { useAppDispatch, useAppSelector } from "@/app/hooks";
|
|
import { removeTempCal } from "@/features/Calendars/CalendarSlice";
|
|
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
|
import { getTempCalendarsListAsync } from "@/features/Calendars/services";
|
|
import { setView } from "@/features/Settings/SettingsSlice";
|
|
import { TextField, useTheme } from "@linagora/twake-mui";
|
|
import { useRef } from "react";
|
|
import { useI18n } from "twake-i18n";
|
|
import { PeopleSearch, User } from "../Attendees/PeopleSearch";
|
|
import { getAccessiblePair } from "@/utils/getAccessiblePair";
|
|
|
|
const requestControllers = new Map<string, AbortController>();
|
|
|
|
export function TempCalendarsInput({
|
|
tempUsers,
|
|
setTempUsers,
|
|
handleToggleEventPreview,
|
|
}: {
|
|
tempUsers: User[];
|
|
setTempUsers: (users: User[]) => void;
|
|
handleToggleEventPreview: () => void;
|
|
}) {
|
|
const dispatch = useAppDispatch();
|
|
const tempcalendars =
|
|
useAppSelector((state) => state.calendars.templist) ?? {};
|
|
const theme = useTheme();
|
|
const { t } = useI18n();
|
|
|
|
const prevUsersRef = useRef<User[]>([]);
|
|
const userColorsRef = useRef(new Map<string, string>());
|
|
|
|
const handleUserChange = async (_: React.SyntheticEvent, users: User[]) => {
|
|
setTempUsers(users);
|
|
|
|
const prevUsers = prevUsersRef.current;
|
|
|
|
const addedUsers = users.filter(
|
|
(u) => !prevUsers.some((p) => p.email === u.email)
|
|
);
|
|
const removedUsers = prevUsers.filter(
|
|
(p) => !users.some((u) => u.email === p.email)
|
|
);
|
|
|
|
prevUsersRef.current = users;
|
|
|
|
if (addedUsers.length > 0) {
|
|
dispatch(setView("calendar"));
|
|
for (const user of addedUsers) {
|
|
const controller = new AbortController();
|
|
requestControllers.set(user.email, controller);
|
|
|
|
if (!userColorsRef.current.has(user.email)) {
|
|
const existingColors = Array.from(userColorsRef.current.values());
|
|
const lightColor = generateDistinctColor(existingColors);
|
|
userColorsRef.current.set(user.email, lightColor);
|
|
}
|
|
const lightColor = userColorsRef.current.get(user.email)!;
|
|
|
|
user.color = {
|
|
light: lightColor,
|
|
dark: getAccessiblePair(lightColor, theme),
|
|
};
|
|
dispatch(
|
|
getTempCalendarsListAsync(user, { signal: controller.signal })
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const user of removedUsers) {
|
|
const controller = requestControllers.get(user.email);
|
|
if (controller) {
|
|
controller.abort();
|
|
requestControllers.delete(user.email);
|
|
}
|
|
|
|
const calIds = buildEmailToCalendarMap(tempcalendars).get(user.email);
|
|
calIds?.forEach((id) => dispatch(removeTempCal(id)));
|
|
userColorsRef.current.delete(user.email);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<PeopleSearch
|
|
objectTypes={["user", "resource"]}
|
|
selectedUsers={tempUsers}
|
|
onChange={handleUserChange}
|
|
onToggleEventPreview={handleToggleEventPreview}
|
|
placeholder={t("peopleSearch.availabilityPlaceholder")}
|
|
inputSlot={(params) => (
|
|
<TextField
|
|
{...params}
|
|
size="small"
|
|
sx={
|
|
tempUsers.length > 0
|
|
? {
|
|
"& .MuiOutlinedInput-root": {
|
|
flexDirection: "column",
|
|
alignItems: "start",
|
|
"& .MuiInputBase-input": {
|
|
width: "100%",
|
|
},
|
|
},
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function buildEmailToCalendarMap(calRecord: Record<string, Calendar>) {
|
|
const map = new Map<string, string[]>();
|
|
for (const [id, cal] of Object.entries(calRecord)) {
|
|
cal.owner?.emails?.forEach((email) => {
|
|
const existing = map.get(email);
|
|
if (existing) {
|
|
existing.push(id);
|
|
} else {
|
|
map.set(email, [id]);
|
|
}
|
|
});
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function extractHSL(hslColor: string): { h: number; s: number; l: number } {
|
|
const match = hslColor.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/);
|
|
if (!match) return { h: 0, s: 70, l: 50 };
|
|
return {
|
|
h: parseInt(match[1], 10),
|
|
s: parseInt(match[2], 10),
|
|
l: parseInt(match[3], 10),
|
|
};
|
|
}
|
|
|
|
function getHueDistance(hue1: number, hue2: number): number {
|
|
const diff = Math.abs(hue1 - hue2);
|
|
return Math.min(diff, 360 - diff);
|
|
}
|
|
|
|
function getColorDistance(color1: string, color2: string): number {
|
|
const hsl1 = extractHSL(color1);
|
|
const hsl2 = extractHSL(color2);
|
|
|
|
const hueDist = getHueDistance(hsl1.h, hsl2.h) / 180;
|
|
const satDist = Math.abs(hsl1.s - hsl2.s) / 100;
|
|
const lightDist = Math.abs(hsl1.l - hsl2.l) / 100;
|
|
|
|
// Weighted distance: hue is most important, then saturation, then lightness
|
|
return Math.sqrt(
|
|
hueDist * hueDist * 4 + satDist * satDist * 1 + lightDist * lightDist * 1
|
|
);
|
|
}
|
|
|
|
function generateDistinctColor(
|
|
existingColors: string[],
|
|
minDistance: number = 0.35,
|
|
maxAttempts: number = 150
|
|
): string {
|
|
// Predefined palette with good variety
|
|
const palette = [
|
|
{ h: 0, s: 75, l: 50 }, // Red
|
|
{ h: 30, s: 80, l: 50 }, // Orange
|
|
{ h: 50, s: 85, l: 50 }, // Yellow-orange
|
|
{ h: 120, s: 70, l: 45 }, // Green
|
|
{ h: 180, s: 70, l: 45 }, // Cyan
|
|
{ h: 210, s: 75, l: 50 }, // Blue
|
|
{ h: 270, s: 70, l: 50 }, // Purple
|
|
{ h: 320, s: 75, l: 50 }, // Magenta
|
|
{ h: 90, s: 65, l: 55 }, // Lime
|
|
{ h: 200, s: 80, l: 45 }, // Deep blue
|
|
{ h: 290, s: 65, l: 55 }, // Light purple
|
|
{ h: 340, s: 75, l: 50 }, // Pink-red
|
|
];
|
|
|
|
// Try palette colors first
|
|
for (const color of palette) {
|
|
const colorStr = `hsl(${color.h}, ${color.s}%, ${color.l}%)`;
|
|
if (existingColors.length === 0) {
|
|
return colorStr;
|
|
}
|
|
|
|
const minDist = Math.min(
|
|
...existingColors.map((c) => getColorDistance(colorStr, c))
|
|
);
|
|
|
|
if (minDist >= minDistance) {
|
|
return colorStr;
|
|
}
|
|
}
|
|
|
|
// If palette exhausted, generate random colors
|
|
let bestColor = `hsl(0, 70%, 50%)`;
|
|
let bestMinDistance = 0;
|
|
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
const h = Math.floor(Math.random() * 360);
|
|
const s = 65 + Math.floor(Math.random() * 20); // 65-85%
|
|
const l = 45 + Math.floor(Math.random() * 15); // 45-60%
|
|
const candidateColor = `hsl(${h}, ${s}%, ${l}%)`;
|
|
|
|
const minDist = Math.min(
|
|
...existingColors.map((c) => getColorDistance(candidateColor, c))
|
|
);
|
|
|
|
if (minDist >= minDistance) {
|
|
return candidateColor;
|
|
}
|
|
|
|
if (minDist > bestMinDistance) {
|
|
bestMinDistance = minDist;
|
|
bestColor = candidateColor;
|
|
}
|
|
}
|
|
|
|
return bestColor;
|
|
}
|