sidebar availability search (#128)
* [123] added people search in left side bar * [#123] added toggle temp calendars * [#123] added tests] * fixup! [123] added people search in left side bar * fixup! [#123] added toggle temp calendars * [#123] fixed event creation on Enter * fixup! [#123] fixed event creation on Enter * fixup! [#123] fixed event creation on Enter * fixup! [#123] fixed event creation on Enter * fixup! [#123] fixed event creation on Enter * fixup! [#123] fixed event creation on Enter * fixup! [#123] fixed event creation on Enter * fixup! [#123] fixed event creation on Enter * [#123] added color diff for temp calendars * fixup! [#123] fixed event creation on Enter * fixup! [#123] added tests] * fixup! [#123] added toggle temp calendars --------- Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
@@ -17,26 +17,33 @@ import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useState, useEffect } from "react";
|
||||
import { searchUsers } from "../../features/User/userAPI";
|
||||
import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
export interface User {
|
||||
email: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
openpaasId: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function PeopleSearch({
|
||||
selectedUsers,
|
||||
onChange,
|
||||
disabled,
|
||||
onToggleEventPreview,
|
||||
}: {
|
||||
selectedUsers: User[];
|
||||
onChange: Function;
|
||||
disabled?: boolean;
|
||||
onToggleEventPreview?: Function;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [options, setOptions] = useState<User[]>([]);
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const delayDebounceFn = setTimeout(async () => {
|
||||
@@ -67,10 +74,25 @@ export function PeopleSearch({
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder="Search user"
|
||||
label="Search user"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && onToggleEventPreview) {
|
||||
e.preventDefault();
|
||||
onToggleEventPreview();
|
||||
}
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
...params.InputProps,
|
||||
startAdornment: (
|
||||
<>
|
||||
<PeopleOutlineOutlinedIcon
|
||||
sx={{ mr: 1, color: "action.active" }}
|
||||
/>
|
||||
{params.InputProps.startAdornment}
|
||||
</>
|
||||
),
|
||||
endAdornment: (
|
||||
<>
|
||||
{loading ? (
|
||||
@@ -101,6 +123,18 @@ export function PeopleSearch({
|
||||
</ListItem>
|
||||
);
|
||||
}}
|
||||
renderValue={(value, getTagProps) =>
|
||||
value.map((option, index) => (
|
||||
<Chip
|
||||
{...getTagProps({ index })}
|
||||
sx={{
|
||||
backgroundColor: option.color,
|
||||
color: theme.palette.getContrastText(option.color ?? "#ffffffff"),
|
||||
}}
|
||||
label={option.displayName}
|
||||
/>
|
||||
))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import interactionPlugin from "@fullcalendar/interaction";
|
||||
import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
|
||||
import ReactCalendar from "react-calendar";
|
||||
import "./Calendar.css";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import EventPopover from "../../features/Events/EventModal";
|
||||
import CalendarPopover from "../../features/Calendars/CalendarModal";
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "../../features/Calendars/CalendarSlice";
|
||||
import ImportAlert from "../../features/Events/ImportAlert";
|
||||
import {
|
||||
computeStartOfTheWeek,
|
||||
formatDateToYYYYMMDDTHHMMSS,
|
||||
getCalendarRange,
|
||||
getDeltaInMilliseconds,
|
||||
@@ -35,15 +36,9 @@ import AddIcon from "@mui/icons-material/Add";
|
||||
import AccessTimeIcon from "@mui/icons-material/AccessTime";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import { userAttendee } from "../../features/User/userDataTypes";
|
||||
import { TempCalendarsInput } from "./TempCalendarsInput";
|
||||
import Button from "@mui/material/Button";
|
||||
|
||||
const computeStartOfTheWeek = (date: Date): Date => {
|
||||
const startOfWeek = new Date(date);
|
||||
startOfWeek.setDate(date.getDate() - ((date.getDay() + 6) % 7)); // Monday
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
return startOfWeek;
|
||||
};
|
||||
|
||||
export default function CalendarApp() {
|
||||
const calendarRef = useRef<CalendarApi | null>(null);
|
||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||
@@ -57,6 +52,8 @@ export default function CalendarApp() {
|
||||
}
|
||||
|
||||
const calendars = useAppSelector((state) => state.calendars.list);
|
||||
const tempcalendars =
|
||||
useAppSelector((state) => state.calendars.templist) ?? {};
|
||||
const pending = useAppSelector((state) => state.calendars.pending);
|
||||
const userId =
|
||||
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
|
||||
@@ -106,35 +103,39 @@ export default function CalendarApp() {
|
||||
calendarRange.start
|
||||
)}_${formatDateToYYYYMMDDTHHMMSS(calendarRange.end)}`;
|
||||
|
||||
let filteredEvents: CalendarEvent[] = [];
|
||||
selectedCalendars.forEach((id) => {
|
||||
if (calendars[id].events) {
|
||||
filteredEvents = filteredEvents
|
||||
.concat(
|
||||
Object.keys(calendars[id].events).map(
|
||||
(eventid) => calendars[id].events[eventid]
|
||||
)
|
||||
)
|
||||
.filter((event) => !(event.status === "CANCELLED"));
|
||||
}
|
||||
});
|
||||
let filteredEvents: CalendarEvent[] = extractEvents(
|
||||
selectedCalendars,
|
||||
calendars
|
||||
);
|
||||
|
||||
let filteredTempEvents: CalendarEvent[] = extractEvents(
|
||||
Object.keys(tempcalendars),
|
||||
tempcalendars
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
selectedCalendars.forEach((id) => {
|
||||
if (!pending && rangeKey) {
|
||||
dispatch(
|
||||
getCalendarDetailAsync({
|
||||
calId: id,
|
||||
match: {
|
||||
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
|
||||
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
updateCalsDetails(
|
||||
selectedCalendars,
|
||||
pending,
|
||||
calendars,
|
||||
rangeKey,
|
||||
dispatch,
|
||||
calendarRange
|
||||
);
|
||||
}, [rangeKey, selectedCalendars]);
|
||||
|
||||
useEffect(() => {
|
||||
updateCalsDetails(
|
||||
Object.keys(tempcalendars),
|
||||
pending,
|
||||
tempcalendars,
|
||||
rangeKey,
|
||||
dispatch,
|
||||
calendarRange,
|
||||
"temp"
|
||||
);
|
||||
}, [rangeKey, Object.keys(tempcalendars).join(",")]);
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [anchorPosition, setAnchorPosition] = useState<{
|
||||
top: number;
|
||||
@@ -142,11 +143,16 @@ export default function CalendarApp() {
|
||||
} | null>(null);
|
||||
const [openEventDisplay, setOpenEventDisplay] = useState(false);
|
||||
const [eventDisplayedId, setEventDisplayedId] = useState("");
|
||||
const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false);
|
||||
const [eventDisplayedCalId, setEventDisplayedCalId] = useState("");
|
||||
const [selectedRange, setSelectedRange] = useState<DateSelectArg | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const [tempEvent, setTempEvent] = useState<CalendarEvent>(
|
||||
{} as CalendarEvent
|
||||
);
|
||||
|
||||
const handleDateSelect = (selectInfo: DateSelectArg) => {
|
||||
setSelectedRange(selectInfo);
|
||||
setAnchorEl(document.body); // fallback: we could use selectInfo.jsEvent.target if from a click
|
||||
@@ -156,6 +162,29 @@ export default function CalendarApp() {
|
||||
calendarRef.current?.unselect();
|
||||
setAnchorEl(null);
|
||||
setSelectedRange(null);
|
||||
selectedCalendars.forEach((calId) =>
|
||||
dispatch(
|
||||
getCalendarDetailAsync({
|
||||
calId,
|
||||
match: {
|
||||
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
|
||||
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
Object.keys(tempcalendars).forEach((calId) =>
|
||||
dispatch(
|
||||
getCalendarDetailAsync({
|
||||
calId,
|
||||
match: {
|
||||
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
|
||||
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
|
||||
},
|
||||
calType: "temp",
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
const handleCloseEventDisplay = () => {
|
||||
setAnchorPosition(null);
|
||||
@@ -267,6 +296,12 @@ export default function CalendarApp() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<TempCalendarsInput
|
||||
setAnchorEl={setAnchorEl}
|
||||
selectedCalendars={selectedCalendars}
|
||||
setSelectedCalendars={setSelectedCalendars}
|
||||
setTempEvent={setTempEvent}
|
||||
/>
|
||||
<CalendarSelection
|
||||
selectedCalendars={selectedCalendars}
|
||||
setSelectedCalendars={setSelectedCalendars}
|
||||
@@ -316,12 +351,11 @@ export default function CalendarApp() {
|
||||
timeGridWeek: { titleFormat: { month: "long", year: "numeric" } },
|
||||
}}
|
||||
dayMaxEvents={true}
|
||||
events={filteredEvents.map((e) => {
|
||||
if (e.calId.split("/")[0] === userId) {
|
||||
return { ...e, editable: true };
|
||||
}
|
||||
return { ...e, editable: false };
|
||||
})}
|
||||
events={eventToFullCalendarFormat(
|
||||
filteredEvents,
|
||||
filteredTempEvents,
|
||||
userId
|
||||
)}
|
||||
weekNumbers
|
||||
weekNumberFormat={{ week: "long" }}
|
||||
slotDuration={"00:30:00"}
|
||||
@@ -379,6 +413,7 @@ export default function CalendarApp() {
|
||||
});
|
||||
setEventDisplayedId(info.event.extendedProps.uid);
|
||||
setEventDisplayedCalId(info.event.extendedProps.calId);
|
||||
setEventDisplayedTemp(info.event._def.extendedProps.temp);
|
||||
}
|
||||
}}
|
||||
eventAllow={(dropInfo, draggedEvent) => {
|
||||
@@ -439,7 +474,7 @@ export default function CalendarApp() {
|
||||
start: computedNewStart,
|
||||
end: computedNewEnd,
|
||||
} as CalendarEvent;
|
||||
console.log(event, newEvent);
|
||||
|
||||
dispatch(
|
||||
putEventAsync({ cal: calendars[newEvent.calId], newEvent })
|
||||
);
|
||||
@@ -451,7 +486,13 @@ export default function CalendarApp() {
|
||||
}}
|
||||
eventContent={(arg) => {
|
||||
const event = arg.event;
|
||||
if (!calendars[arg.event._def.extendedProps.calId]) return;
|
||||
if (
|
||||
(!event._def.extendedProps.temp &&
|
||||
!calendars[arg.event._def.extendedProps.calId]) ||
|
||||
(event._def.extendedProps.temp &&
|
||||
!tempcalendars[arg.event._def.extendedProps.calId])
|
||||
)
|
||||
return;
|
||||
|
||||
const attendees = event._def.extendedProps.attendee || [];
|
||||
const isPrivate =
|
||||
@@ -460,15 +501,19 @@ export default function CalendarApp() {
|
||||
let Icon = null;
|
||||
let titleStyle: React.CSSProperties = {};
|
||||
const ownerEmails = new Set(
|
||||
calendars[arg.event._def.extendedProps.calId].ownerEmails?.map(
|
||||
(email) => email.toLowerCase()
|
||||
)
|
||||
(event._def.extendedProps.temp ? tempcalendars : calendars)[
|
||||
arg.event._def.extendedProps.calId
|
||||
].ownerEmails?.map((email) => email.toLowerCase())
|
||||
);
|
||||
|
||||
const delegated = (
|
||||
event._def.extendedProps.temp ? tempcalendars : calendars
|
||||
)[arg.event._def.extendedProps.calId].delegated;
|
||||
const showSpecialDisplay = attendees.filter((att: userAttendee) =>
|
||||
ownerEmails.has(att.cal_address.toLowerCase())
|
||||
);
|
||||
if (!showSpecialDisplay[0]) return;
|
||||
switch (showSpecialDisplay[0].partstat) {
|
||||
if (!delegated && showSpecialDisplay.length === 0) return null;
|
||||
switch (showSpecialDisplay?.[0]?.partstat) {
|
||||
case "DECLINED":
|
||||
Icon = null;
|
||||
titleStyle.textDecoration = "line-through";
|
||||
@@ -545,11 +590,13 @@ export default function CalendarApp() {
|
||||
selectedRange={selectedRange}
|
||||
setSelectedRange={setSelectedRange}
|
||||
calendarRef={calendarRef}
|
||||
event={tempEvent}
|
||||
/>
|
||||
{openEventDisplay && eventDisplayedId && eventDisplayedCalId && (
|
||||
<EventPreviewModal
|
||||
eventId={eventDisplayedId}
|
||||
calId={eventDisplayedCalId}
|
||||
tempEvent={eventDisplayedTemp}
|
||||
anchorPosition={anchorPosition}
|
||||
open={openEventDisplay}
|
||||
onClose={handleCloseEventDisplay}
|
||||
@@ -559,3 +606,65 @@ export default function CalendarApp() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function eventToFullCalendarFormat(
|
||||
filteredEvents: CalendarEvent[],
|
||||
filteredTempEvents: CalendarEvent[],
|
||||
userId: string | undefined
|
||||
) {
|
||||
return filteredEvents
|
||||
.concat(filteredTempEvents.map((e) => ({ ...e, temp: true })))
|
||||
.map((e) => {
|
||||
if (e.calId.split("/")[0] === userId) {
|
||||
return { ...e, editable: true };
|
||||
}
|
||||
return { ...e, editable: false };
|
||||
});
|
||||
}
|
||||
|
||||
function extractEvents(
|
||||
selectedCalendars: string[],
|
||||
calendars: Record<string, Calendars>
|
||||
) {
|
||||
let filteredEvents: CalendarEvent[] = [];
|
||||
selectedCalendars.forEach((id) => {
|
||||
if (calendars[id].events) {
|
||||
filteredEvents = filteredEvents
|
||||
.concat(
|
||||
Object.keys(calendars[id].events).map(
|
||||
(eventid) => calendars[id].events[eventid]
|
||||
)
|
||||
)
|
||||
.filter((event) => !(event.status === "CANCELLED"));
|
||||
}
|
||||
});
|
||||
return filteredEvents;
|
||||
}
|
||||
|
||||
function updateCalsDetails(
|
||||
selectedCalendars: string[],
|
||||
pending: boolean,
|
||||
calendars: Record<string, Calendars>,
|
||||
rangeKey: string,
|
||||
dispatch: Function,
|
||||
calendarRange: { start: Date; end: Date },
|
||||
calType?: "temp"
|
||||
) {
|
||||
selectedCalendars.forEach((id) => {
|
||||
if (Object.keys(calendars[id].events).length > 0) {
|
||||
return;
|
||||
}
|
||||
if (!pending && rangeKey) {
|
||||
dispatch(
|
||||
getCalendarDetailAsync({
|
||||
calId: id,
|
||||
match: {
|
||||
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
|
||||
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
|
||||
},
|
||||
calType,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ function CalendarAccordion({
|
||||
const allCalendars = useAppSelector((state) => state.calendars.list);
|
||||
|
||||
const [expended, setExpended] = useState(defaultExpanded);
|
||||
if (calendars.length === 0) return null;
|
||||
if (calendars.length === 0 && !defaultExpanded) return null;
|
||||
|
||||
return (
|
||||
<Accordion defaultExpanded={defaultExpanded} expanded={expended}>
|
||||
@@ -129,7 +129,6 @@ export default function CalendarSelection({
|
||||
calendars={delegatedCalendars}
|
||||
selectedCalendars={selectedCalendars}
|
||||
handleToggle={handleCalendarToggle}
|
||||
defaultExpanded
|
||||
setOpen={(id: string) => {
|
||||
setAnchorElCal(document.body);
|
||||
setSelectedCalId(id);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState, useRef, useMemo } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import {
|
||||
getTempCalendarsListAsync,
|
||||
removeTempCal,
|
||||
} from "../../features/Calendars/CalendarSlice";
|
||||
import { Calendars } from "../../features/Calendars/CalendarTypes";
|
||||
import { CalendarEvent } from "../../features/Events/EventsTypes";
|
||||
import { User, PeopleSearch } from "../Attendees/PeopleSearch";
|
||||
|
||||
const requestControllers = new Map<string, AbortController>();
|
||||
|
||||
export function TempCalendarsInput({
|
||||
setAnchorEl,
|
||||
setTempEvent,
|
||||
selectedCalendars,
|
||||
setSelectedCalendars,
|
||||
}: {
|
||||
setAnchorEl: Function;
|
||||
setTempEvent: Function;
|
||||
selectedCalendars: string[];
|
||||
setSelectedCalendars: Function;
|
||||
}) {
|
||||
const [tempUsers, setTempUsers] = useState<User[]>([]);
|
||||
const dispatch = useAppDispatch();
|
||||
const tempcalendars =
|
||||
useAppSelector((state) => state.calendars.templist) ?? {};
|
||||
const calendars = useAppSelector((state) => state.calendars.list);
|
||||
|
||||
const prevUsersRef = useRef<User[]>([]);
|
||||
const userColors = new Map<string, string>();
|
||||
|
||||
const handleUserChange = async (_: any, 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;
|
||||
|
||||
const { calendarsToImport, calendarsToToggle } = getCalendarsFromUsersDelta(
|
||||
addedUsers,
|
||||
buildEmailToCalendarMap(calendars),
|
||||
selectedCalendars
|
||||
);
|
||||
|
||||
if (calendarsToImport.length > 0) {
|
||||
for (const user of calendarsToImport) {
|
||||
const controller = new AbortController();
|
||||
requestControllers.set(user.email, controller);
|
||||
|
||||
if (!userColors.has(user.email)) {
|
||||
const existingColors = new Set(userColors.values());
|
||||
userColors.set(user.email, generateUserColor(existingColors));
|
||||
}
|
||||
|
||||
user.color = userColors.get(user.email)!;
|
||||
|
||||
dispatch(
|
||||
getTempCalendarsListAsync(user, { signal: controller.signal })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (calendarsToToggle.length > 0) {
|
||||
setSelectedCalendars((prev: string[]) => [
|
||||
...new Set([...prev, ...calendarsToToggle]),
|
||||
]);
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEventPreview = () => {
|
||||
const newEvent: CalendarEvent = {
|
||||
title: "New Event",
|
||||
attendee: tempUsers.map((u) => ({
|
||||
cn: u.displayName,
|
||||
cal_address: u.email,
|
||||
partstat: "NEED-ACTION",
|
||||
role: "REQ-PARTICIPANT",
|
||||
rsvp: "TRUE",
|
||||
cutype: "INDIVIDUAL",
|
||||
})),
|
||||
} as CalendarEvent;
|
||||
|
||||
setTempEvent(newEvent);
|
||||
setAnchorEl(document.body);
|
||||
};
|
||||
|
||||
return (
|
||||
<PeopleSearch
|
||||
selectedUsers={tempUsers}
|
||||
onChange={handleUserChange}
|
||||
onToggleEventPreview={handleToggleEventPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getCalendarsFromUsersDelta(
|
||||
addedUsers: User[],
|
||||
emailToCalendarId: Map<string, string[]>,
|
||||
selectedCalendars: string[]
|
||||
) {
|
||||
const selectedSet = new Set(selectedCalendars);
|
||||
|
||||
const calendarsToImport: User[] = [];
|
||||
const calendarsToToggle: string[] = [];
|
||||
|
||||
for (const user of addedUsers) {
|
||||
const calIds = emailToCalendarId.get(user.email) ?? [];
|
||||
|
||||
if (!calIds || calIds.every((calId) => !selectedSet.has(calId))) {
|
||||
calendarsToImport.push(user);
|
||||
} else {
|
||||
// calIds.forEach((calId) => calendarsToToggle.push(calId));
|
||||
}
|
||||
}
|
||||
|
||||
return { calendarsToImport, calendarsToToggle };
|
||||
}
|
||||
|
||||
function buildEmailToCalendarMap(calRecord: Record<string, Calendars>) {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const [id, cal] of Object.entries(calRecord)) {
|
||||
cal.ownerEmails?.forEach((email) => {
|
||||
const existing = map.get(email);
|
||||
if (existing) {
|
||||
existing.push(id);
|
||||
} else {
|
||||
map.set(email, [id]);
|
||||
}
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function generateUserColor(existingColors: Set<string>): string {
|
||||
let color: string;
|
||||
do {
|
||||
const hue = Math.floor(Math.random() * 360);
|
||||
color = `hsl(${hue}, 70%, 50%)`;
|
||||
} while (existingColors.has(color));
|
||||
return color;
|
||||
}
|
||||
Reference in New Issue
Block a user