Color picker rework (#232)

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2025-10-24 01:20:56 +02:00
committed by GitHub
parent 8681e88390
commit 34363562cc
23 changed files with 504 additions and 183 deletions
@@ -82,7 +82,7 @@ describe("Calendar API", () => {
it("patch Calendar", async () => { it("patch Calendar", async () => {
const calId = "calId"; const calId = "calId";
const calLink = "/calendars/calId.json"; const calLink = "/calendars/calId.json";
const color = "calId"; const color = { light: "calIdLight", dark: "calIdDark" };
const name = "new cal"; const name = "new cal";
const desc = "desc"; const desc = "desc";
@@ -96,7 +96,7 @@ describe("Calendar API", () => {
body: JSON.stringify({ body: JSON.stringify({
"dav:name": "new cal", "dav:name": "new cal",
"caldav:description": "desc", "caldav:description": "desc",
"apple:color": "calId", "apple:color": "calIdLight",
}), }),
}); });
}); });
+11
View File
@@ -31,6 +31,7 @@
"moment-timezone": "^0.5.48", "moment-timezone": "^0.5.48",
"openid-client": "^6.5.3", "openid-client": "^6.5.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-i18next": "^13.5.0", "react-i18next": "^13.5.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
@@ -11795,6 +11796,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-colorful": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.1.0", "version": "19.1.0",
"license": "MIT", "license": "MIT",
+1
View File
@@ -26,6 +26,7 @@
"moment-timezone": "^0.5.48", "moment-timezone": "^0.5.48",
"openid-client": "^6.5.3", "openid-client": "^6.5.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-i18next": "^13.5.0", "react-i18next": "^13.5.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
+4 -3
View File
@@ -10,13 +10,14 @@ import { searchUsers } from "../../features/User/userAPI";
import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined"; import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined";
import Chip from "@mui/material/Chip"; import Chip from "@mui/material/Chip";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { getAccessiblePair } from "../Calendar/utils/calendarColorsUtils";
export interface User { export interface User {
email: string; email: string;
displayName: string; displayName: string;
avatarUrl: string; avatarUrl: string;
openpaasId: string; openpaasId: string;
color?: string; color?: Record<string, string>;
} }
export function PeopleSearch({ export function PeopleSearch({
@@ -150,8 +151,8 @@ export function PeopleSearch({
const label = isString ? option : option.displayName || option.email; const label = isString ? option : option.displayName || option.email;
const chipColor = isString const chipColor = isString
? theme.palette.grey[300] ? theme.palette.grey[300]
: (option.color ?? theme.palette.grey[300]); : (option.color?.light ?? theme.palette.grey[300]);
const textColor = theme.palette.getContrastText(chipColor); const textColor = getAccessiblePair(chipColor, theme);
return ( return (
<Chip <Chip
+12 -1
View File
@@ -40,6 +40,8 @@ import momentTimezonePlugin from "@fullcalendar/moment-timezone";
import { TimezoneSelector } from "./TimezoneSelector"; import { TimezoneSelector } from "./TimezoneSelector";
import { MiniCalendar } from "./MiniCalendar"; import { MiniCalendar } from "./MiniCalendar";
import { User } from "../Attendees/PeopleSearch"; import { User } from "../Attendees/PeopleSearch";
import { useTheme } from "@mui/material/styles";
import { updateDarkColor } from "./utils/calendarColorsUtils";
interface CalendarAppProps { interface CalendarAppProps {
calendarRef: React.RefObject<CalendarApi | null>; calendarRef: React.RefObject<CalendarApi | null>;
@@ -58,7 +60,7 @@ export default function CalendarApp({
const userId = const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const theme = useTheme();
useEffect(() => { useEffect(() => {
if (!tokens || !userId) { if (!tokens || !userId) {
dispatch(push("/")); dispatch(push("/"));
@@ -108,6 +110,15 @@ export default function CalendarApp({
} }
}, [calendars, userId]); }, [calendars, userId]);
useEffect(() => {
updateDarkColor(calendars, theme, dispatch);
}, [
theme,
Object.values(calendars)
.map((c) => c.color?.dark)
.join(","),
]);
useEffect(() => { useEffect(() => {
const validCalendarIds = new Set(Object.keys(calendars)); const validCalendarIds = new Set(Object.keys(calendars));
setSelectedCalendars((prev) => setSelectedCalendars((prev) =>
+226 -21
View File
@@ -1,36 +1,241 @@
import { Box } from "@mui/material"; import AddIcon from "@mui/icons-material/Add";
import CheckIcon from "@mui/icons-material/Check";
const defaultColors = ["#34d399", "#fbbf24", "#f87171", "#60a5fa", "#f472b6"]; import {
Box,
Button,
Popover,
TextField,
Typography,
useTheme,
} from "@mui/material";
import { useState } from "react";
import { HexColorPicker } from "react-colorful";
import { defaultColors, getAccessiblePair } from "./utils/calendarColorsUtils";
export function ColorPicker({ export function ColorPicker({
selectedColor, selectedColor,
colors = defaultColors, colors = defaultColors,
onChange, onChange,
}: { }: {
selectedColor?: string; selectedColor: Record<string, string>;
colors?: string[]; colors?: Record<string, string>[];
onChange: (color: string) => void; onChange: (color: Record<string, string>) => void;
}) { }) {
const [customColor, setCustomColor] = useState(
!colors.find((c) => c.light === selectedColor?.light)
? selectedColor
: undefined
);
return ( return (
<Box display="flex" alignItems="center" gap={1}> <Box display="flex" alignItems="center" gap={1}>
{colors.map((c) => ( {colors.map((c) => (
<Box <ColorBox
key={c} key={c.light}
role="button" color={c}
aria-label={`select color ${c}`} onChange={onChange}
onClick={() => onChange(c)} selectedColor={selectedColor}
style={{
width: 20,
height: 20,
borderRadius: "50%",
backgroundColor: c,
cursor: "pointer",
border:
selectedColor === c ? "2px solid black" : "2px solid transparent",
transition: "all 0.2s",
}}
/> />
))} ))}
{customColor && (
<ColorBox
color={customColor ?? {}}
onChange={onChange}
selectedColor={selectedColor}
/>
)}
<ColorPickerBox
onChange={(c) => {
onChange(c);
setCustomColor(c);
}}
selectedColor={selectedColor}
/>
</Box> </Box>
); );
} }
function ColorBox({
color,
onChange,
selectedColor,
}: {
color: Record<string, string>;
onChange: (color: Record<string, string>) => void;
selectedColor: Record<string, string>;
}) {
return (
<Box
role="button"
aria-label={`select color ${color.light}`}
onClick={() => onChange(color)}
style={{
width: "46px",
height: "32px",
padding: 0,
borderRadius: "4px",
backgroundColor: color.light,
cursor: "pointer",
transition: "all 0.2s",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Box
style={{
height: "7px",
width: "100%",
borderRadius: "4px 4px 0px 0px",
backgroundColor: color.dark,
}}
></Box>
<CheckIcon
style={{
visibility:
selectedColor?.light === color.light ? "visible" : "hidden",
color: color.dark,
}}
/>
</Box>
);
}
function ColorPickerBox({
onChange,
selectedColor,
}: {
onChange: (color: Record<string, string>) => void;
selectedColor: Record<string, string>;
}) {
const [oldColor] = useState(
selectedColor ?? { light: "#ffffff", dark: "#808080" }
);
const [color, setColor] = useState(oldColor);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl);
const theme = useTheme();
const handleClick = (event: any) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
onChange(oldColor);
setAnchorEl(null);
};
const handleSave = () => {
onChange(color);
setAnchorEl(null);
};
const handleColorChange = (c: string) => {
const newLight = c;
const newColor = {
light: newLight,
dark: getAccessiblePair(newLight, theme),
};
setColor(newColor);
onChange(newColor);
};
return (
<>
<Box
key={"colorPicker"}
role="button"
aria-label={`select custom color`}
onClick={handleClick}
style={{
width: "46px",
height: "32px",
padding: 0,
borderRadius: "4px",
border: "1px solid #CBD2E0",
backgroundColor: "#FFF",
cursor: "pointer",
transition: "all 0.2s",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Box
style={{
height: "7px",
width: "100%",
borderRadius: "4px 4px 0px 0px",
backgroundColor: "#CBD2E0",
}}
></Box>
<AddIcon
style={{
color: "#CBD2E0",
}}
/>
</Box>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: "center",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
slotProps={{
paper: {
style: {
padding: "24px",
width: "294px",
borderRadius: "8px",
boxShadow: "0px 1px 3px #3C404326",
},
},
}}
>
<Typography variant="subtitle1" fontWeight="600">
Choose custom colour
</Typography>
<Typography variant="body2" sx={{ mb: 2, color: "text.secondary" }}>
Choose a background colour for this calendar
</Typography>
<Box sx={{ mb: 2 }}>
<HexColorPicker
color={color.light}
onChange={handleColorChange}
style={{ width: "100%" }}
/>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ mr: 1 }}>
Hex
</Typography>
<TextField
value={color.light?.toUpperCase()}
onChange={(e) => handleColorChange(e.target.value)}
variant="standard"
size="small"
slotProps={{ inputLabel: { shrink: true } }}
/>
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
<Button onClick={handleClose}>Cancel</Button>
<Button
variant="contained"
onClick={handleSave}
sx={{ textTransform: "none" }}
>
Save
</Button>
</Box>
</Popover>
</>
);
}
+8 -7
View File
@@ -12,6 +12,7 @@ import { ResponsiveDialog } from "../Dialog";
import { AccessTab } from "./AccessTab"; import { AccessTab } from "./AccessTab";
import { ImportTab } from "./ImportTab"; import { ImportTab } from "./ImportTab";
import { SettingsTab } from "./SettingsTab"; import { SettingsTab } from "./SettingsTab";
import { defaultColors } from "./utils/calendarColorsUtils";
function CalendarPopover({ function CalendarPopover({
open, open,
@@ -34,7 +35,7 @@ function CalendarPopover({
// existing calendar params // existing calendar params
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [color, setColor] = useState(""); const [color, setColor] = useState<Record<string, string>>(defaultColors[0]);
const [visibility, setVisibility] = useState<"private" | "public">("public"); const [visibility, setVisibility] = useState<"private" | "public">("public");
// import tab state // import tab state
@@ -45,7 +46,7 @@ function CalendarPopover({
// new calendar params (for import new) // new calendar params (for import new)
const [newCalName, setNewCalName] = useState(""); const [newCalName, setNewCalName] = useState("");
const [newCalDescription, setNewCalDescription] = useState(""); const [newCalDescription, setNewCalDescription] = useState("");
const [newCalColor, setNewCalColor] = useState(""); const [newCalColor, setNewCalColor] = useState(defaultColors[0]);
const [newCalVisibility, setNewCalVisibility] = useState< const [newCalVisibility, setNewCalVisibility] = useState<
"public" | "private" "public" | "private"
>("public"); >("public");
@@ -55,13 +56,13 @@ function CalendarPopover({
if (calendar) { if (calendar) {
setName(calendar.name); setName(calendar.name);
setDescription(calendar.description ?? ""); setDescription(calendar.description ?? "");
setColor(calendar.color ?? ""); setColor(calendar.color ?? defaultColors[0]);
setVisibility(calendar.visibility ?? "public"); setVisibility(calendar.visibility ?? "public");
setImportTarget(calendar.id ?? "new"); setImportTarget(calendar.id ?? "new");
} else { } else {
setName(""); setName("");
setDescription(""); setDescription("");
setColor(""); setColor(defaultColors[0]);
setVisibility("public"); setVisibility("public");
setImportTarget("new"); setImportTarget("new");
} }
@@ -90,7 +91,7 @@ function CalendarPopover({
calId: string, calId: string,
name: string, name: string,
desc: string, desc: string,
color: string, color: Record<string, string>,
visibility: string visibility: string
) => { ) => {
await dispatch( await dispatch(
@@ -160,7 +161,7 @@ function CalendarPopover({
onClose(e, reason); onClose(e, reason);
setName(""); setName("");
setDescription(""); setDescription("");
setColor(""); setColor(defaultColors[0]);
setTab("settings"); setTab("settings");
setVisibility("public"); setVisibility("public");
setImportTarget("new"); setImportTarget("new");
@@ -168,7 +169,7 @@ function CalendarPopover({
setNewCalName(""); setNewCalName("");
setNewCalDescription(""); setNewCalDescription("");
setNewCalColor(""); setNewCalColor(defaultColors[0]);
setNewCalVisibility("public"); setNewCalVisibility("public");
}; };
+99 -112
View File
@@ -16,6 +16,9 @@ import { addSharedCalendarAsync } from "../../features/Calendars/CalendarSlice";
import { ColorPicker } from "./CalendarColorPicker"; import { ColorPicker } from "./CalendarColorPicker";
import { Calendars } from "../../features/Calendars/CalendarTypes"; import { Calendars } from "../../features/Calendars/CalendarTypes";
import { User, PeopleSearch } from "../Attendees/PeopleSearch"; import { User, PeopleSearch } from "../Attendees/PeopleSearch";
import { getAccessiblePair } from "./utils/calendarColorsUtils";
import { useTheme } from "@mui/material/styles";
import { ResponsiveDialog } from "../Dialog";
interface CalendarWithOwner { interface CalendarWithOwner {
cal: Record<string, any>; cal: Record<string, any>;
@@ -29,8 +32,10 @@ function CalendarItem({
}: { }: {
cal: CalendarWithOwner; cal: CalendarWithOwner;
onRemove: () => void; onRemove: () => void;
onColorChange: (color: string) => void; onColorChange: (color: Record<string, string>) => void;
}) { }) {
const theme = useTheme();
return ( return (
<Box <Box
key={cal.owner.email + cal.cal["dav:name"]} key={cal.owner.email + cal.cal["dav:name"]}
@@ -65,7 +70,10 @@ function CalendarItem({
<Box display="flex" alignItems="center" gap={1}> <Box display="flex" alignItems="center" gap={1}>
<ColorPicker <ColorPicker
selectedColor={cal.cal["apple:color"]} selectedColor={{
light: cal.cal["apple:color"],
dark: getAccessiblePair(cal.cal["apple:color"], theme),
}}
onChange={onColorChange} onChange={onColorChange}
/> />
<IconButton size="small" onClick={onRemove}> <IconButton size="small" onClick={onRemove}>
@@ -85,7 +93,10 @@ function SelectedCalendarsList({
calendars: Record<string, Calendars>; calendars: Record<string, Calendars>;
selectedCal: CalendarWithOwner[]; selectedCal: CalendarWithOwner[];
onRemove: (cal: CalendarWithOwner) => void; onRemove: (cal: CalendarWithOwner) => void;
onColorChange: (cal: CalendarWithOwner, color: string) => void; onColorChange: (
cal: CalendarWithOwner,
color: Record<string, string>
) => void;
}) { }) {
if (selectedCal.length === 0) return null; if (selectedCal.length === 0) return null;
@@ -194,7 +205,13 @@ export default function CalendarSearch({
addSharedCalendarAsync({ addSharedCalendarAsync({
userId: openpaasId, userId: openpaasId,
calId, calId,
cal: { ...cal, color: cal.cal["apple:color"] }, cal: {
...cal,
color: {
light: cal.cal["apple:color"],
dark: cal.cal["X-TWAKE-Dark-theme-color"],
},
},
}) })
); );
return cal.cal._links.self.href return cal.cal._links.self.href
@@ -212,122 +229,92 @@ export default function CalendarSearch({
}; };
return ( return (
<Modal <ResponsiveDialog
open={open} open={open}
contentSx={{ paddingTop: "8px !important" }}
onClose={() => { onClose={() => {
onClose({}, "backdropClick"); onClose({}, "backdropClick");
setSelectedCalendars([]); setSelectedCalendars([]);
setSelectedUsers([]); setSelectedUsers([]);
}} }}
title="Browse other calendars"
actions={
<>
<Button
variant="outlined"
onClick={() => onClose({}, "backdropClick")}
>
Cancel
</Button>
<Button variant="contained" onClick={handleSave}>
Add
</Button>
</>
}
> >
<Box <PeopleSearch
style={{ objectTypes={["user"]}
position: "absolute", selectedUsers={selectedUsers}
left: "50%", onChange={async (event: any, value: User[]) => {
top: "20%", setSelectedUsers(value);
transform: "translate( -50%)",
width: "50vw", const cals = await Promise.all(
maxHeight: "80vh", value.map(async (user: User) => {
const cals = (await getCalendars(
user.openpaasId,
"sharedPublic=true&withRights=true"
)) as Record<string, any>;
return cals._embedded?.["dav:calendar"]
? cals._embedded["dav:calendar"].map(
(cal: Record<string, any>) => ({ cal, owner: user })
)
: { cal: undefined, owner: user };
})
);
setSelectedCalendars(cals.flat());
}} }}
> />
<Card
style={{
padding: 16,
borderRadius: 12,
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
display: "flex",
flexDirection: "column",
maxHeight: "80vh",
}}
>
<Box style={{ position: "absolute", top: 8, right: 8 }}>
<IconButton
size="small"
onClick={() => onClose({}, "backdropClick")}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<CardHeader <SelectedCalendarsList
title="Browse other calendars" calendars={calendars}
style={{ paddingBottom: 0 }} selectedCal={selectedCal}
/> onRemove={(cal) => {
<CardContent style={{ flex: 1, overflow: "auto" }}> setSelectedCalendars((prev) =>
<PeopleSearch prev.filter(
objectTypes={["user"]} (c) => c.cal._links.self.href !== cal.cal._links.self.href
selectedUsers={selectedUsers} )
onChange={async (event: any, value: User[]) => { );
setSelectedUsers(value); if (
!selectedCal.find(
const cals = await Promise.all( (c) =>
value.map(async (user: User) => { cal.owner.email === c.owner.email &&
const cals = (await getCalendars( c.cal._links.self.href !== cal.cal._links.self.href
user.openpaasId, )
"sharedPublic=true&withRights=true" ) {
)) as Record<string, any>; setSelectedUsers((prev) =>
return cals._embedded?.["dav:calendar"] prev.filter((u) => u.email !== cal.owner.email)
? cals._embedded["dav:calendar"].map( );
(cal: Record<string, any>) => ({ cal, owner: user }) }
) }}
: { cal: undefined, owner: user }; onColorChange={(cal, color) =>
}) setSelectedCalendars((prev) =>
); prev.map((prevcal) =>
prevcal.owner.email === cal.owner.email &&
setSelectedCalendars(cals.flat()); prevcal.cal._links.self.href === cal.cal._links.self.href
}} ? {
/> ...prevcal,
cal: {
<SelectedCalendarsList ...prevcal.cal,
calendars={calendars} "apple:color": color.light,
selectedCal={selectedCal} "X-TWAKE-Dark-theme-color": color.dark,
onRemove={(cal) => { },
setSelectedCalendars((prev) => }
prev.filter( : prevcal
(c) => c.cal._links.self.href !== cal.cal._links.self.href )
) )
); }
if ( />
!selectedCal.find( </ResponsiveDialog>
(c) =>
cal.owner.email === c.owner.email &&
c.cal._links.self.href !== cal.cal._links.self.href
)
) {
setSelectedUsers((prev) =>
prev.filter((u) => u.email !== cal.owner.email)
);
}
}}
onColorChange={(cal, color) =>
setSelectedCalendars((prev) =>
prev.map((prevcal) =>
prevcal.owner.email === cal.owner.email &&
prevcal.cal._links.self.href === cal.cal._links.self.href
? {
...prevcal,
cal: { ...prevcal.cal, "apple:color": color },
}
: prevcal
)
)
}
/>
</CardContent>
<CardActions style={{ justifyContent: "flex-end", gap: 8 }}>
<Button
variant="outlined"
onClick={() => onClose({}, "backdropClick")}
>
Cancel
</Button>
<Button variant="contained" onClick={handleSave}>
Add
</Button>
</CardActions>
</Card>
</Box>
</Modal>
); );
} }
+10 -3
View File
@@ -218,8 +218,8 @@ function CalendarSelector({
<label> <label>
<Checkbox <Checkbox
sx={{ sx={{
color: calendars[id].color, color: calendars[id].color?.light,
"&.Mui-checked": { color: calendars[id].color }, "&.Mui-checked": { color: calendars[id].color?.light },
}} }}
size="small" size="small"
checked={selectedCalendars.includes(id)} checked={selectedCalendars.includes(id)}
@@ -232,7 +232,14 @@ function CalendarSelector({
</IconButton> </IconButton>
</div> </div>
<Menu id={id} anchorEl={anchorEl} open={open} onClose={handleClose}> <Menu id={id} anchorEl={anchorEl} open={open} onClose={handleClose}>
<MenuItem onClick={() => setOpen()}>Modify</MenuItem> <MenuItem
onClick={() => {
setOpen();
handleClose();
}}
>
Modify
</MenuItem>
{!isDefault && <Divider />} {!isDefault && <Divider />}
{!isDefault && ( {!isDefault && (
<MenuItem onClick={() => setDeletePopupOpen(!deletePopupOpen)}> <MenuItem onClick={() => setDeletePopupOpen(!deletePopupOpen)}>
+1 -1
View File
@@ -30,7 +30,7 @@ export function ImportTab({
setName: Function; setName: Function;
description: string; description: string;
setDescription: Function; setDescription: Function;
color: string; color: Record<string, string>;
setColor: Function; setColor: Function;
visibility: "public" | "private"; visibility: "public" | "private";
setVisibility: Function; setVisibility: Function;
+1 -1
View File
@@ -29,7 +29,7 @@ export function SettingsTab({
setName: Function; setName: Function;
description: string; description: string;
setDescription: Function; setDescription: Function;
color: string; color: Record<string, string>;
setColor: Function; setColor: Function;
visibility: "public" | "private"; visibility: "public" | "private";
setVisibility: Function; setVisibility: Function;
@@ -1,3 +1,4 @@
import { useTheme } from "@mui/material/styles";
import { useRef } from "react"; import { useRef } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { import {
@@ -6,6 +7,7 @@ import {
} from "../../features/Calendars/CalendarSlice"; } from "../../features/Calendars/CalendarSlice";
import { Calendars } from "../../features/Calendars/CalendarTypes"; import { Calendars } from "../../features/Calendars/CalendarTypes";
import { User, PeopleSearch } from "../Attendees/PeopleSearch"; import { User, PeopleSearch } from "../Attendees/PeopleSearch";
import { getAccessiblePair } from "./utils/calendarColorsUtils";
const requestControllers = new Map<string, AbortController>(); const requestControllers = new Map<string, AbortController>();
@@ -26,6 +28,7 @@ export function TempCalendarsInput({
const tempcalendars = const tempcalendars =
useAppSelector((state) => state.calendars.templist) ?? {}; useAppSelector((state) => state.calendars.templist) ?? {};
const calendars = useAppSelector((state) => state.calendars.list); const calendars = useAppSelector((state) => state.calendars.list);
const theme = useTheme();
const prevUsersRef = useRef<User[]>([]); const prevUsersRef = useRef<User[]>([]);
const userColors = new Map<string, string>(); const userColors = new Map<string, string>();
@@ -59,8 +62,12 @@ export function TempCalendarsInput({
const existingColors = new Set(userColors.values()); const existingColors = new Set(userColors.values());
userColors.set(user.email, generateUserColor(existingColors)); userColors.set(user.email, generateUserColor(existingColors));
} }
const lightColor = userColors.get(user.email)!;
user.color = userColors.get(user.email)!; user.color = {
light: lightColor,
dark: getAccessiblePair(lightColor, theme),
};
dispatch( dispatch(
getTempCalendarsListAsync(user, { signal: controller.signal }) getTempCalendarsListAsync(user, { signal: controller.signal })
@@ -127,7 +127,9 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
); );
const delegated = calendar.delegated; const delegated = calendar.delegated;
let Icon = null; let Icon = null;
let titleStyle: React.CSSProperties = {}; let titleStyle: React.CSSProperties = {
color: event._def.extendedProps.colors?.dark,
};
const showSpecialDisplay = attendees.filter((att: userAttendee) => const showSpecialDisplay = attendees.filter((att: userAttendee) =>
ownerEmails.has(att.cal_address.toLowerCase()) ownerEmails.has(att.cal_address.toLowerCase())
@@ -265,7 +267,7 @@ function RenderEventTitle(
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
backgroundColor: calendar.color, backgroundColor: calendar.color?.light,
color: "white", color: "white",
borderRadius: "4px", borderRadius: "4px",
border: "1px", border: "1px",
@@ -0,0 +1,55 @@
import { darken, getContrastRatio, lighten, Theme } from "@mui/material";
import { ThunkDispatch } from "@reduxjs/toolkit";
import { updateCalColor } from "../../../features/Calendars/CalendarSlice";
import { Calendars } from "../../../features/Calendars/CalendarTypes";
export function updateDarkColor(
calendars: Record<string, Calendars>,
theme: Theme,
dispatch: ThunkDispatch<any, any, any>
) {
Object.values(calendars).forEach((cal) => {
if (!cal?.color?.light) return;
const baseColor = cal.color.light;
const isDefault = Object.values(defaultColors).find(
(c) => c.light === baseColor
);
const darkColor = isDefault
? isDefault.dark
: getAccessiblePair(baseColor, theme);
dispatch(
updateCalColor({
id: cal.id,
color: { light: baseColor, dark: darkColor },
})
);
});
}
export function getAccessiblePair(baseColor: string, theme: Theme): string {
const contrastToBlack = getContrastRatio(baseColor, "#000");
const contrastToWhite = getContrastRatio(baseColor, "#fff");
const isLight = contrastToBlack > contrastToWhite;
const adjusted = isLight ? darken(baseColor, 0.6) : lighten(baseColor, 0.7);
// Check if contrast meets 4.5
const contrast = getContrastRatio(baseColor, adjusted);
console.log(isLight, contrast);
if (contrast >= 4.5) return adjusted;
if (isLight) {
return "#ffffffff";
}
return theme.palette.getContrastText(baseColor);
}
export const defaultColors = [
{ light: "#D0ECDA", dark: "#329655" },
{ light: "#FAE3CE", dark: "#E15300" },
{ light: "#F5CFD0", dark: "#BE0103" },
{ light: "#AFCBEF", dark: "#0654B1" },
];
@@ -62,9 +62,9 @@ export const eventToFullCalendarFormat = (
.concat(filteredTempEvents.map((e) => ({ ...e, temp: true }))) .concat(filteredTempEvents.map((e) => ({ ...e, temp: true })))
.map((e) => { .map((e) => {
if (e.calId.split("/")[0] === userId) { if (e.calId.split("/")[0] === userId) {
return { ...e, editable: true }; return { ...e, colors: e.color, color: e.color?.light, editable: true };
} }
return { ...e, editable: false }; return { ...e, colors: e.color, color: e.color?.light, editable: false };
}); });
}; };
+8 -5
View File
@@ -34,7 +34,7 @@ export async function getCalendar(
export async function postCalendar( export async function postCalendar(
userId: string, userId: string,
calId: string, calId: string,
color: string, color: Record<string, string>,
name: string, name: string,
desc: string desc: string
) { ) {
@@ -67,7 +67,10 @@ export async function addSharedCalendar(
"calendarserver:source": { "calendarserver:source": {
acl: cal.cal.acl, acl: cal.cal.acl,
calendarHomeId: cal.cal.id, calendarHomeId: cal.cal.id,
color: cal.cal["apple:color"], color: {
light: cal.cal["apple:color"],
dark: cal.cal["X-TWAKE-Dark-theme-color"],
},
description: cal.cal["caldav:description"], description: cal.cal["caldav:description"],
href: cal.cal._links.self.href, href: cal.cal._links.self.href,
id: cal.cal.id, id: cal.cal.id,
@@ -81,7 +84,7 @@ export async function addSharedCalendar(
export async function proppatchCalendar( export async function proppatchCalendar(
calLink: string, calLink: string,
patch: { name: string; desc: string; color: string } patch: { name: string; desc: string; color: Record<string, string> }
) { ) {
const body: Record<string, string> = {}; const body: Record<string, string> = {};
if (patch.name) { if (patch.name) {
@@ -90,8 +93,8 @@ export async function proppatchCalendar(
if (patch.desc) { if (patch.desc) {
body["caldav:description"] = patch.desc; body["caldav:description"] = patch.desc;
} }
if (patch.color) { if (patch.color.light) {
body["apple:color"] = patch.color; body["apple:color"] = patch.color.light;
} }
const response = await api(`dav${calLink}`, { const response = await api(`dav${calLink}`, {
method: "PROPPATCH", method: "PROPPATCH",
+42 -14
View File
@@ -53,7 +53,10 @@ export const getCalendarsListAsync = createAsyncThunk<
const id = source.replace("/calendars/", "").replace(".json", ""); const id = source.replace("/calendars/", "").replace(".json", "");
const visibility = getCalendarVisibility(cal["acl"]); const visibility = getCalendarVisibility(cal["acl"]);
const ownerData: any = await getUserDetails(id.split("/")[0]); const ownerData: any = await getUserDetails(id.split("/")[0]);
const color = cal["apple:color"]; const color = {
light: cal["apple:color"] ?? "#006BD8",
dark: cal["X-TWAKE-Dark-theme-color"] ?? "#FFF",
};
importedCalendars[id] = { importedCalendars[id] = {
id, id,
name, name,
@@ -106,7 +109,10 @@ export const getTempCalendarsListAsync = createAsyncThunk<
ownerEmails: ownerData.emails, ownerEmails: ownerData.emails,
description, description,
delegated, delegated,
color: tempUser.color ?? "#a8a8a8ff", color: {
light: tempUser.color?.light ?? "#a8a8a8ff",
dark: tempUser.color?.dark ?? "#a8a8a8ff",
},
visibility, visibility,
events: {}, events: {},
}; };
@@ -157,7 +163,7 @@ export const putEventAsync = createAsyncThunk<
return vevents.map((vevent: any[]) => { return vevents.map((vevent: any[]) => {
return parseCalendarEvent( return parseCalendarEvent(
vevent[1], vevent[1],
cal.color ?? "", cal.color ?? {},
cal.id, cal.id,
eventURL, eventURL,
valarm valarm
@@ -187,12 +193,12 @@ export const patchCalendarAsync = createAsyncThunk<
{ {
calId: string; calId: string;
calLink: string; calLink: string;
patch: { name: string; desc: string; color: string }; patch: { name: string; desc: string; color: Record<string, string> };
}, // Return type }, // Return type
{ {
calId: string; calId: string;
calLink: string; calLink: string;
patch: { name: string; desc: string; color: string }; patch: { name: string; desc: string; color: Record<string, string> };
} // Arg type } // Arg type
>("calendars/patchCalendar", async ({ calId, calLink, patch }) => { >("calendars/patchCalendar", async ({ calId, calLink, patch }) => {
await proppatchCalendar(calLink, patch); await proppatchCalendar(calLink, patch);
@@ -238,7 +244,7 @@ export const moveEventAsync = createAsyncThunk<
const vevents = eventdata.data[2] as any[][]; const vevents = eventdata.data[2] as any[][];
const eventURL = eventdata._links.self.href; const eventURL = eventdata._links.self.href;
return vevents.map((vevent: any[]) => { return vevents.map((vevent: any[]) => {
return parseCalendarEvent(vevent[1], cal.color ?? "", cal.id, eventURL); return parseCalendarEvent(vevent[1], cal.color ?? {}, cal.id, eventURL);
}); });
} }
); );
@@ -304,13 +310,19 @@ export const createCalendarAsync = createAsyncThunk<
{ {
userId: string; userId: string;
calId: string; calId: string;
color: string; color: Record<string, string>;
name: string; name: string;
desc: string; desc: string;
owner: string; owner: string;
ownerEmails: string[]; ownerEmails: string[];
}, // Return type }, // Return type
{ userId: string; calId: string; color: string; name: string; desc: string } // Arg type {
userId: string;
calId: string;
color: Record<string, string>;
name: string;
desc: string;
} // Arg type
>("calendars/createCalendar", async ({ userId, calId, color, name, desc }) => { >("calendars/createCalendar", async ({ userId, calId, color, name, desc }) => {
await postCalendar(userId, calId, color, name, desc); await postCalendar(userId, calId, color, name, desc);
const ownerData: any = await getUserDetails(userId.split("/")[0]); const ownerData: any = await getUserDetails(userId.split("/")[0]);
@@ -331,7 +343,7 @@ export const createCalendarAsync = createAsyncThunk<
export const addSharedCalendarAsync = createAsyncThunk< export const addSharedCalendarAsync = createAsyncThunk<
{ {
calId: string; calId: string;
color: string; color: Record<string, string>;
link: string; link: string;
name: string; name: string;
desc: string; desc: string;
@@ -352,7 +364,10 @@ export const addSharedCalendarAsync = createAsyncThunk<
calId: cal.cal._links.self.href calId: cal.cal._links.self.href
.replace("/calendars/", "") .replace("/calendars/", "")
.replace(".json", ""), .replace(".json", ""),
color: cal.cal["apple:color"], color: {
light: cal.cal["apple:color"],
dark: "#000",
},
link: `/calendars/${userId}/${calId}.json`, link: `/calendars/${userId}/${calId}.json`,
desc: cal.cal["caldav:description"], desc: cal.cal["caldav:description"],
name: cal.cal["dav:name"], name: cal.cal["dav:name"],
@@ -388,12 +403,15 @@ const CalendarSlice = createSlice({
timeZone: string; timeZone: string;
}, },
reducers: { reducers: {
createCalendar: (state, action: PayloadAction<Record<string, string>>) => { createCalendar: (
state,
action: PayloadAction<Record<string, string | Record<string, string>>>
) => {
const id = Date.now().toString(36); const id = Date.now().toString(36);
state.list[id] = {} as Calendars; state.list[id] = {} as Calendars;
state.list[id].name = action.payload.name; state.list[id].name = action.payload.name as string;
state.list[id].color = action.payload.color; state.list[id].color = action.payload.color as Record<string, string>;
state.list[id].description = action.payload.description; state.list[id].description = action.payload.description as string;
state.list[id].events = {}; state.list[id].events = {};
}, },
addEvent: ( addEvent: (
@@ -450,6 +468,15 @@ const CalendarSlice = createSlice({
if (!state.list[action.payload]) return; if (!state.list[action.payload]) return;
state.list[action.payload].lastCacheCleared = Date.now(); state.list[action.payload].lastCacheCleared = Date.now();
}, },
updateCalColor: (
state,
action: PayloadAction<{
id: string;
color: { light: string; dark: string };
}>
) => {
state.list[action.payload.id].color = action.payload.color;
},
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder builder
@@ -719,5 +746,6 @@ export const {
emptyEventsCal, emptyEventsCal,
setTimeZone, setTimeZone,
clearFetchCache, clearFetchCache,
updateCalColor,
} = CalendarSlice.actions; } = CalendarSlice.actions;
export default CalendarSlice.reducer; export default CalendarSlice.reducer;
+1 -1
View File
@@ -6,7 +6,7 @@ export interface Calendars {
name: string; name: string;
delegated?: boolean; delegated?: boolean;
prodid?: string; prodid?: string;
color?: string; color?: Record<string, string>;
ownerEmails?: string[]; ownerEmails?: string[];
owner: string; owner: string;
description?: string; description?: string;
+1 -1
View File
@@ -36,7 +36,7 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
const eventjson = parseCalendarEvent( const eventjson = parseCalendarEvent(
targetVevent[1], targetVevent[1],
event.color ?? "", event.color ?? {},
event.calId, event.calId,
event.URL event.URL
); );
+4 -2
View File
@@ -48,6 +48,7 @@ import { userAttendee } from "../User/userDataTypes";
import { getEvent } from "./EventApi"; import { getEvent } from "./EventApi";
import { formatLocalDateTime } from "../../components/Event/EventFormFields"; import { formatLocalDateTime } from "../../components/Event/EventFormFields";
import { CalendarEvent, RepetitionObject } from "./EventsTypes"; import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { light } from "@mui/material/styles/createPalette";
export default function EventDisplayModal({ export default function EventDisplayModal({
eventId, eventId,
@@ -224,7 +225,8 @@ export default function EventDisplayModal({
<Typography variant="body2"> <Typography variant="body2">
<CircleIcon <CircleIcon
style={{ style={{
color: userPersonnalCalendars[index].color ?? "#3788D8", color:
userPersonnalCalendars[index].color?.light ?? "#3788D8",
width: 12, width: 12,
height: 12, height: 12,
}} }}
@@ -238,7 +240,7 @@ export default function EventDisplayModal({
<Typography variant="body2"> <Typography variant="body2">
<CircleIcon <CircleIcon
style={{ style={{
color: calendars[index].color ?? "#3788D8", color: calendars[index].color?.light ?? "#3788D8",
width: 12, width: 12,
height: 12, height: 12,
}} }}
+1 -1
View File
@@ -577,7 +577,7 @@ export default function EventPreviewModal({
<CalendarTodayIcon style={{ fontSize: 16 }} /> <CalendarTodayIcon style={{ fontSize: 16 }} />
<CircleIcon <CircleIcon
style={{ style={{
color: calendar.color ?? "#3788D8", color: calendar.color?.light ?? "#3788D8",
width: 12, width: 12,
height: 12, height: 12,
}} }}
+1 -1
View File
@@ -17,7 +17,7 @@ export interface CalendarEvent {
attendee: userAttendee[]; attendee: userAttendee[];
stamp?: string; stamp?: string;
sequence?: Number; sequence?: Number;
color?: string; color?: Record<string, string>;
allday?: boolean; allday?: boolean;
error?: string; error?: string;
status?: string; status?: string;
+2 -2
View File
@@ -1,5 +1,5 @@
import { userAttendee } from "../User/userDataTypes"; import { userAttendee } from "../User/userDataTypes";
import { AlarmObject, CalendarEvent } from "./EventsTypes"; import { AlarmObject, CalendarEvent, RepetitionObject } from "./EventsTypes";
import ICAL from "ical.js"; import ICAL from "ical.js";
import { TIMEZONES } from "../../utils/timezone-data"; import { TIMEZONES } from "../../utils/timezone-data";
import moment from "moment"; import moment from "moment";
@@ -7,7 +7,7 @@ type RawEntry = [string, Record<string, string>, string, any];
export function parseCalendarEvent( export function parseCalendarEvent(
data: RawEntry[], data: RawEntry[],
color: string, color: Record<string, string>,
calendarid: string, calendarid: string,
eventURL: string, eventURL: string,
valarm?: RawEntry[] valarm?: RawEntry[]