[#162] changed preview modal looks to follow figma (#163)

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2025-10-07 11:20:09 +02:00
committed by GitHub
parent 40c383c334
commit 3f8f3eff23
14 changed files with 731 additions and 619 deletions
+2 -7
View File
@@ -20,7 +20,7 @@ import {
} from "../../utils/dateUtils";
import { Calendars } from "../../features/Calendars/CalendarTypes";
import { push } from "redux-first-history";
import EventPreviewModal from "../../features/Events/EventDisplayPreview";
import EventPreviewModal from "../Event/EventDisplayPreview";
import { createSelector } from "@reduxjs/toolkit";
import AddIcon from "@mui/icons-material/Add";
import { TempCalendarsInput } from "./TempCalendarsInput";
@@ -161,10 +161,7 @@ export default function CalendarApp({
}, [rangeKey, Object.keys(tempcalendars).join(","), pending]);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [anchorPosition, setAnchorPosition] = useState<{
top: number;
left: number;
} | null>(null);
const [openEventDisplay, setOpenEventDisplay] = useState(false);
const [eventDisplayedId, setEventDisplayedId] = useState("");
const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false);
@@ -187,7 +184,6 @@ export default function CalendarApp({
calendarRange,
dispatch,
setOpenEventDisplay,
setAnchorPosition,
setEventDisplayedId,
setEventDisplayedCalId,
setEventDisplayedTemp,
@@ -431,7 +427,6 @@ export default function CalendarApp({
eventId={eventDisplayedId}
calId={eventDisplayedCalId}
tempEvent={eventDisplayedTemp}
anchorPosition={anchorPosition}
open={openEventDisplay}
onClose={eventHandlers.handleCloseEventDisplay}
/>
@@ -217,7 +217,7 @@ function CalendarSelector({
<div>
<label>
<Checkbox
sx={{
style={{
color: calendars[id].color,
"&.Mui-checked": { color: calendars[id].color },
}}
@@ -20,7 +20,6 @@ export interface EventHandlersProps {
calendarRange: { start: Date; end: Date };
dispatch: any;
setOpenEventDisplay: (open: boolean) => void;
setAnchorPosition: (position: { top: number; left: number } | null) => void;
setEventDisplayedId: (id: string) => void;
setEventDisplayedCalId: (id: string) => void;
setEventDisplayedTemp: (temp: boolean) => void;
@@ -37,7 +36,6 @@ export const createEventHandlers = (props: EventHandlersProps) => {
calendarRange,
dispatch,
setOpenEventDisplay,
setAnchorPosition,
setEventDisplayedId,
setEventDisplayedCalId,
setEventDisplayedTemp,
@@ -79,7 +77,6 @@ export const createEventHandlers = (props: EventHandlersProps) => {
};
const handleCloseEventDisplay = () => {
setAnchorPosition(null);
setOpenEventDisplay(false);
};
@@ -109,10 +106,6 @@ export const createEventHandlers = (props: EventHandlersProps) => {
} else {
console.log(info.event);
setOpenEventDisplay(true);
setAnchorPosition({
top: info.jsEvent.clientY,
left: info.jsEvent.clientX,
});
setEventDisplayedId(info.event.extendedProps.uid);
setEventDisplayedCalId(info.event.extendedProps.calId);
setEventDisplayedTemp(info.event._def.extendedProps.temp);
@@ -136,7 +136,12 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
const handleEventContent = (arg: any) => {
const event = arg.event;
const props = event._def.extendedProps;
const { calId, temp, attendees = [], class: classification } = props;
const {
calId,
temp,
attendee: attendees = [],
class: classification,
} = props;
const calendarsSource = temp ? tempcalendars : calendars;
const calendar = calendarsSource[calId];
@@ -23,13 +23,12 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => {
]),
handleCloseEventDisplay: useCallback(
eventHandlers.handleCloseEventDisplay,
[props.setAnchorPosition, props.setOpenEventDisplay]
[props.setOpenEventDisplay]
),
handleMonthUp: useCallback(eventHandlers.handleMonthUp, []),
handleMonthDown: useCallback(eventHandlers.handleMonthDown, []),
handleEventClick: useCallback(eventHandlers.handleEventClick, [
props.setOpenEventDisplay,
props.setAnchorPosition,
props.setEventDisplayedId,
props.setEventDisplayedCalId,
props.setEventDisplayedTemp,
@@ -0,0 +1,452 @@
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import CircleIcon from "@mui/icons-material/Circle";
import CloseIcon from "@mui/icons-material/Close";
import EditIcon from "@mui/icons-material/Edit";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
import SubjectIcon from "@mui/icons-material/Subject";
import VideocamIcon from "@mui/icons-material/Videocam";
import {
Box,
Button,
ButtonGroup,
DialogActions,
Divider,
IconButton,
Menu,
MenuItem,
Typography,
} from "@mui/material";
import AvatarGroup from "@mui/material/AvatarGroup";
import { useEffect, useState } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import {
deleteEventAsync,
getEventAsync,
putEventAsync,
} from "../../features/Calendars/CalendarSlice";
import { dlEvent } from "../../features/Events/EventApi";
import EventDisplayModal from "../../features/Events/EventDisplay";
import { ResponsiveDialog } from "../Dialog";
import EventDuplication from "./EventDuplicate";
import { InfoRow } from "./InfoRow";
import { renderAttendeeBadge } from "./utils/eventUtils";
export default function EventPreviewModal({
eventId,
calId,
tempEvent,
open,
onClose,
}: {
eventId: string;
calId: string;
tempEvent?: boolean;
open: boolean;
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
}) {
const dispatch = useAppDispatch();
const calendars = useAppSelector((state) => state.calendars);
const calendar = tempEvent
? calendars.templist[calId]
: calendars.list[calId];
const event = calendar.events[eventId];
const user = useAppSelector((state) => state.user);
const [showAllAttendees, setShowAllAttendees] = useState(false);
const [openFullDisplay, setOpenFullDisplay] = useState(false);
const [toggleActionMenu, setToggleActionMenu] = useState<Element | null>(
null
);
const mailSpaUrl = (window as any).MAIL_SPA_URL ?? null;
useEffect(() => {
if (!event || !calendar) {
onClose({}, "backdropClick");
}
}, [event, calendar, onClose]);
if (!event || !calendar) return null;
const attendeeDisplayLimit = 3;
const attendees =
event.attendee?.filter(
(a) => a.cal_address !== event.organizer?.cal_address
) || [];
const visibleAttendees = showAllAttendees
? attendees
: attendees.slice(0, attendeeDisplayLimit);
const currentUserAttendee = event.attendee?.find(
(person) => person.cal_address === user.userData.email
);
const organizer = event.attendee?.find(
(a) => a.cal_address === event.organizer?.cal_address
);
function handleRSVP(rsvp: string) {
const newEvent = {
...event,
attendee: event.attendee?.map((a) =>
a.cal_address === user.userData.email ? { ...a, partstat: rsvp } : a
),
};
dispatch(putEventAsync({ cal: calendar, newEvent }));
onClose({}, "backdropClick");
}
return (
<>
<ResponsiveDialog
open={open}
onClose={() => onClose({}, "backdropClick")}
style={{ overflow: "auto" }}
title={
event.title && (
<>
<DialogActions>
<Box>
{(window as any).DEBUG && (
<IconButton
onClick={async () => {
const icsContent = await dlEvent(event);
const blob = new Blob([icsContent], {
type: "text/calendar",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${eventId}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}}
>
<FileDownloadOutlinedIcon />
</IconButton>
)}
{user.userData.email === event.organizer?.cal_address && (
<IconButton
size="small"
onClick={async () => {
setOpenFullDisplay(!openFullDisplay);
await dispatch(getEventAsync(event));
}}
>
<EditIcon />
</IconButton>
)}
<IconButton
size="small"
onClick={(e) => setToggleActionMenu(e.currentTarget)}
>
<MoreVertIcon />
</IconButton>
<Menu
open={Boolean(toggleActionMenu)}
onClose={() => setToggleActionMenu(null)}
anchorEl={toggleActionMenu}
>
{mailSpaUrl && attendees.length > 0 && (
<MenuItem
onClick={() =>
window.open(
`${mailSpaUrl}/mailto/?uri=mailto:${event.attendee
.map((a) => a.cal_address)
.filter((mail) => mail !== user.userData.email)
.join(",")}?subject=${event.title}`
)
}
>
Email attendees
</MenuItem>
)}
<EventDuplication event={event} onClose={onClose} />
{user.userData.email === event.organizer?.cal_address && (
<MenuItem
onClick={() => {
onClose({}, "backdropClick");
dispatch(
deleteEventAsync({
calId,
eventId,
eventURL: event.URL,
})
);
}}
>
Delete event
</MenuItem>
)}
</Menu>
<IconButton
size="small"
onClick={() => onClose({}, "backdropClick")}
>
<CloseIcon />
</IconButton>
</Box>
</DialogActions>
<Typography
variant="inherit"
fontWeight="bold"
style={{
wordBreak: "break-word",
}}
gutterBottom
>
{event.title}
</Typography>
<Typography variant="body2" color="textSecondary" gutterBottom>
{formatDate(event.start, event.allday)}
{event.end &&
formatEnd(event.start, event.end, event.allday) &&
` ${formatEnd(event.start, event.end, event.allday)}`}
</Typography>
</>
)
}
actions={
<>
{currentUserAttendee &&
event.uid?.split("/")[1] &&
"event is a reccuring event, the interraction between acceptance and recurrence is broken and to avoid loosing reccuring events by accepting or refusing them this feature isn't available for now"}
{currentUserAttendee && !event.uid?.split("/")[1] && (
<Box style={{ display: "flex", flexDirection: "row" }}>
<Typography variant="body2">Attending?</Typography>
<ButtonGroup size="small" fullWidth>
<Button
color={
currentUserAttendee?.partstat === "ACCEPTED"
? "success"
: "primary"
}
onClick={() => handleRSVP("ACCEPTED")}
>
Accept
</Button>
<Button
color={
currentUserAttendee?.partstat === "TENTATIVE"
? "warning"
: "primary"
}
onClick={() => handleRSVP("TENTATIVE")}
>
Maybe
</Button>
<Button
color={
currentUserAttendee?.partstat === "DECLINED"
? "error"
: "primary"
}
onClick={() => handleRSVP("DECLINED")}
>
Decline
</Button>
</ButtonGroup>
</Box>
)}
</>
}
>
{/* Video */}
{event.x_openpass_videoconference && (
<InfoRow
icon={<VideocamIcon style={{ fontSize: 18 }} />}
content={
<Button
variant="contained"
onClick={() => window.open(event.x_openpass_videoconference)}
>
Join the video conference
</Button>
}
/>
)}
{/* Attendees */}
{attendees?.length > 0 && (
<>
<InfoRow
icon={<PeopleAltOutlinedIcon />}
content={
<Box
style={{
marginBottom: 1,
display: "flex",
flexDirection: "row",
}}
>
<Box>
<Typography>{attendees.length} guests</Typography>
<Typography>
{
attendees.filter((a) => a.partstat === "ACCEPTED")
.length
}{" "}
yes,{" "}
{
attendees.filter((a) => a.partstat === "DECLINED")
.length
}{" "}
no
</Typography>
</Box>
{!showAllAttendees && (
<AvatarGroup max={attendeeDisplayLimit}>
{organizer &&
renderAttendeeBadge(
organizer,
"org",
showAllAttendees,
true
)}
{visibleAttendees.map((a, idx) =>
renderAttendeeBadge(a, idx.toString(), showAllAttendees)
)}
</AvatarGroup>
)}
<Typography
variant="body2"
color="primary"
style={{ cursor: "pointer", marginTop: 0.5 }}
onClick={() => setShowAllAttendees(!showAllAttendees)}
>
{showAllAttendees ? "Show less" : `Show more `}
</Typography>
</Box>
}
/>
</>
)}
{showAllAttendees &&
organizer &&
renderAttendeeBadge(organizer, "org", showAllAttendees, true)}
{showAllAttendees &&
visibleAttendees.map((a, idx) =>
renderAttendeeBadge(a, idx.toString(), showAllAttendees)
)}
{/* Location */}
{event.location && (
<InfoRow icon={<LocationOnOutlinedIcon />} text={event.location} />
)}
{/* Description */}
{event.description && (
<InfoRow icon={<SubjectIcon />} text={event.description} />
)}
{/* Description */}
{event.alarm && (
<InfoRow
icon={<NotificationsNoneIcon />}
text={`${event.alarm.trigger} before by ${event.alarm.action}`}
/>
)}
{/* Error */}
{event.error && (
<InfoRow
icon={<ErrorOutlineIcon color="error" style={{ fontSize: 18 }} />}
text={event.error}
error
/>
)}
{/* Calendar color dot */}
<Box
style={{
display: "flex",
alignItems: "center",
gap: 1,
marginBottom: 2,
}}
>
<CalendarTodayIcon style={{ fontSize: 16 }} />
<CircleIcon
style={{
color: calendar.color ?? "#3788D8",
width: 12,
height: 12,
}}
/>
<Typography variant="body2">{calendar.name}</Typography>
</Box>
{currentUserAttendee && !event.repetition && (
<>
<Divider variant="fullWidth" />
</>
)}
</ResponsiveDialog>
<EventDisplayModal
open={openFullDisplay}
onClose={() => setOpenFullDisplay(false)}
eventId={eventId}
calId={calId}
/>
</>
);
}
function formatDate(date: Date | string, allday?: boolean) {
if (allday) {
return new Date(date).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
weekday: "long",
day: "numeric",
});
} else {
return new Date(date).toLocaleString(undefined, {
year: "numeric",
month: "long",
weekday: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
}
function formatEnd(start: Date | string, end: Date | string, allday?: boolean) {
const startDate = new Date(start);
const endDate = new Date(end);
const sameDay =
startDate.getFullYear() === endDate.getFullYear() &&
startDate.getMonth() === endDate.getMonth() &&
startDate.getDate() === endDate.getDate();
if (allday) {
return sameDay
? null
: endDate.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
} else {
if (sameDay) {
return endDate.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
}
return endDate.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
}
+4 -5
View File
@@ -1,4 +1,4 @@
import { IconButton } from "@mui/material";
import { IconButton, MenuItem } from "@mui/material";
import { CalendarEvent } from "../../features/Events/EventsTypes";
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
import { useRef, useState } from "react";
@@ -30,14 +30,13 @@ export default function EventDuplication({
};
return (
<>
<IconButton
size="small"
<MenuItem
onClick={() => {
setOpenModal(true);
}}
>
<AddToPhotosIcon fontSize="small" />
</IconButton>
Duplicate event
</MenuItem>
<EventPopover
anchorEl={null}
open={openModal}
+91
View File
@@ -0,0 +1,91 @@
import React from "react";
import { Box, Typography, Link } from "@mui/material";
type InfoRowProps = {
icon: React.ReactNode;
text?: string;
error?: boolean;
data?: string; // optional link target
content?: React.ReactNode; // if provided, overrides text rendering
};
function detectUrls(text: string) {
// Simple regex that captures whole URLs without splitting them apart
const urlRegex = /(https?:\/\/[^\s]+|www\.[^\s]+)/gi;
const parts = [];
let lastIndex = 0;
text.replace(urlRegex, (match, _, offset) => {
// Push the text before the match
if (lastIndex < offset) {
parts.push(
<React.Fragment key={lastIndex}>
{text.slice(lastIndex, offset)}
</React.Fragment>
);
}
// Normalize href
const href = match.startsWith("http") ? match : `https://${match}`;
parts.push(
<Link
key={offset}
href={href}
target="_blank"
rel="noopener noreferrer"
underline="always"
>
{match}
</Link>
);
lastIndex = offset + match.length;
return match;
});
// Push remaining text after last URL
if (lastIndex < text.length) {
parts.push(
<React.Fragment key={lastIndex}>{text.slice(lastIndex)}</React.Fragment>
);
}
return parts;
}
export function InfoRow({
icon,
text,
error = false,
data,
content,
}: InfoRowProps) {
return (
<Box style={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
{icon}
{content ? (
content
) : (
<Typography
variant="body2"
color={error ? "error" : "textPrimary"}
style={{ wordBreak: "break-word" }}
>
{data ? (
<Link
href={data}
target="_blank"
rel="noopener noreferrer"
underline="always"
>
{text}
</Link>
) : text ? (
detectUrls(text)
) : null}
</Typography>
)}
</Box>
);
}
+105
View File
@@ -0,0 +1,105 @@
import CancelIcon from "@mui/icons-material/Cancel";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import Avatar from "@mui/material/Avatar";
import Badge from "@mui/material/Badge";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { userAttendee } from "../../../features/User/userDataTypes";
export function renderAttendeeBadge(
a: userAttendee,
key: string,
isFull?: boolean,
isOrganizer?: boolean
) {
const classIcon =
a.partstat === "ACCEPTED" ? (
<CheckCircleIcon fontSize="inherit" color="success" />
) : a.partstat === "DECLINED" ? (
<CancelIcon fontSize="inherit" color="error" />
) : null;
if (!isFull) {
return <Avatar {...stringAvatar(a.cn || a.cal_address)} />;
} else {
return (
<Box
key={key}
style={{
display: "flex",
alignItems: "center",
gap: 1.5,
marginBottom: 0.5,
padding: 0.5,
borderRadius: 1,
}}
>
<Badge
overlap="circular"
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
badgeContent={
classIcon && (
<Box
style={{
fontSize: 14,
lineHeight: 0,
backgroundColor: "white",
borderRadius: "50%",
padding: "1px",
}}
>
{classIcon}
</Box>
)
}
>
<Avatar {...stringAvatar(a.cn || a.cal_address)} />
</Badge>
<Box style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
<Typography
variant="body2"
noWrap
style={{
maxWidth: "180px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{a.cn || a.cal_address}
</Typography>
{isOrganizer && (
<Typography
variant="caption"
color="text.secondary"
style={{ fontStyle: "italic" }}
>
Organizer
</Typography>
)}
</Box>
</Box>
);
}
}
export function stringToColor(string: string) {
let hash = 0;
for (let i = 0; i < string.length; i++) {
hash = string.charCodeAt(i) + ((hash << 5) - hash);
}
let color = "#";
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
color += `00${value.toString(16)}`.slice(-2);
}
return color;
}
export function stringAvatar(name: string) {
return {
style: { backgroundColor: stringToColor(name) },
children: name[0],
};
}
+1 -1
View File
@@ -6,7 +6,7 @@ import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import "./Menubar.styl";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { stringToColor } from "../../features/Events/EventDisplay";
import { stringToColor } from "../Event/utils/eventUtils";
import {
Avatar,
IconButton,