Merge pull request #504 from linagora/UI/update-header-bar

UI/update header bar
This commit is contained in:
lenhanphung
2026-02-05 19:18:30 +07:00
committed by GitHub
36 changed files with 1021 additions and 317 deletions
+57 -56
View File
@@ -1,7 +1,9 @@
import { Box, Button, TextField } from "@linagora/twake-mui";
import { Description as DescriptionIcon } from "@mui/icons-material";
import { TextField } from "@linagora/twake-mui";
import { Notes as NotesIcon } from "@mui/icons-material";
import React from "react";
import { useI18n } from "twake-i18n";
import { FieldWithLabel } from "./components/FieldWithLabel";
import { SectionPreviewRow } from "./components/SectionPreviewRow";
export function AddDescButton({
showDescription,
@@ -9,73 +11,72 @@ export function AddDescButton({
showMore,
description,
setDescription,
buttonVariant,
buttonColor,
}: {
showDescription: boolean;
setShowDescription: (b: boolean) => void;
showMore: boolean;
description: string;
setDescription: (d: string) => void;
buttonVariant?: "text" | "outlined" | "contained";
buttonColor?:
| "inherit"
| "primary"
| "secondary"
| "success"
| "error"
| "info"
| "warning";
}) {
const { t } = useI18n();
const descriptionInputRef = React.useRef<HTMLTextAreaElement>(null);
React.useEffect(() => {
if (showDescription) {
descriptionInputRef.current?.focus();
}
}, [showDescription]);
const descriptionField = (
<FieldWithLabel
label={t("event.form.description")}
isExpanded={showMore}
sx={{ padding: 0, margin: 0 }}
>
<TextField
fullWidth
label=""
inputRef={descriptionInputRef}
inputProps={{ "aria-label": t("event.form.description") }}
placeholder={t("event.form.descriptionPlaceholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
size="small"
margin="dense"
multiline
minRows={2}
maxRows={10}
sx={{
"& .MuiInputBase-root": {
maxHeight: "33%",
overflowY: "auto",
padding: 0,
},
"& textarea": {
resize: "vertical",
},
}}
/>
</FieldWithLabel>
);
if (showMore) {
return descriptionField;
}
return (
<>
{!showDescription && (
<FieldWithLabel label=" " isExpanded={showMore}>
<Box display="flex" gap={1}>
<Button
startIcon={<DescriptionIcon />}
onClick={() => setShowDescription(true)}
size="medium"
variant={buttonVariant}
color={buttonColor}
>
{t("event.form.addDescription")}
</Button>
</Box>
</FieldWithLabel>
)}
{showDescription && (
<FieldWithLabel
label={t("event.form.description")}
isExpanded={showMore}
sx={{ padding: 0, margin: 0 }}
>
<TextField
fullWidth
label=""
inputProps={{ "aria-label": t("event.form.description") }}
placeholder={t("event.form.descriptionPlaceholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
size="small"
margin="dense"
multiline
minRows={2}
maxRows={10}
sx={{
"& .MuiInputBase-root": {
maxHeight: "33%",
overflowY: "auto",
padding: 0,
},
"& textarea": {
resize: "vertical",
},
}}
/>
<FieldWithLabel label="" isExpanded={showMore}>
<SectionPreviewRow
icon={<NotesIcon />}
onClick={() => setShowDescription(true)}
>
{t("event.form.addDescription")}
</SectionPreviewRow>
</FieldWithLabel>
)}
{showDescription && descriptionField}
</>
);
}
+312 -160
View File
@@ -5,6 +5,7 @@ import iconCamera from "@/static/images/icon-camera.svg";
import {
addVideoConferenceToDescription,
generateMeetingLink,
removeVideoConferenceFromDescription,
} from "@/utils/videoConferenceUtils";
import {
Box,
@@ -20,12 +21,16 @@ import {
ToggleButton,
ToggleButtonGroup,
Typography,
useTheme,
} from "@linagora/twake-mui";
import { alpha } from "@mui/material/styles";
import {
Close as DeleteIcon,
ContentCopy as CopyIcon,
Public as PublicIcon,
LocationOn as LocationIcon,
} from "@mui/icons-material";
import SquareRoundedIcon from "@mui/icons-material/SquareRounded";
import LockOutlineIcon from "@mui/icons-material/LockOutline";
import React from "react";
import { useI18n } from "twake-i18n";
@@ -35,7 +40,9 @@ import { SnackbarAlert } from "../Loading/SnackBarAlert";
import { TimezoneAutocomplete } from "../Timezone/TimezoneAutocomplete";
import { AddDescButton } from "./AddDescButton";
import { DateTimeFields } from "./components/DateTimeFields";
import { DateTimeSummary } from "./components/DateTimeSummary";
import { FieldWithLabel } from "./components/FieldWithLabel";
import { SectionPreviewRow } from "./components/SectionPreviewRow";
import RepeatEvent from "./EventRepeat";
import { useAllDayToggle } from "./hooks/useAllDayToggle";
import { combineDateTime, splitDateTime } from "./utils/dateTimeHelpers";
@@ -156,6 +163,7 @@ export default function EventFormFields({
onHasEndDateChangedChange,
}: EventFormFieldsProps) {
const { t } = useI18n();
const theme = useTheme();
// Internal state for 4 separate fields
const [startDate, setStartDate] = React.useState("");
@@ -166,10 +174,26 @@ export default function EventFormFields({
// Track if user has manually changed end date in extended mode
const [hasEndDateChanged, setHasEndDateChanged] = React.useState(false);
// Reset hasEndDateChanged when modal closes
// Track if user has clicked on datetime clickable section in normal mode
// Once clicked, normal mode will always show full fields until modal closes
const [hasClickedDateTimeSection, setHasClickedDateTimeSection] =
React.useState(false);
// Track if user has clicked on location section in normal mode
const [hasClickedLocationSection, setHasClickedLocationSection] =
React.useState(false);
// Track if user has clicked on calendar section in normal mode
const [hasClickedCalendarSection, setHasClickedCalendarSection] =
React.useState(false);
// Reset hasEndDateChanged and hasClickedDateTimeSection when modal closes
React.useEffect(() => {
if (!isOpen) {
setHasEndDateChanged(false);
setHasClickedDateTimeSection(false);
setHasClickedLocationSection(false);
setHasClickedCalendarSection(false);
}
}, [isOpen]);
@@ -210,10 +234,18 @@ export default function EventFormFields({
// Ref for title input field to enable auto-focus
const titleInputRef = React.useRef<HTMLInputElement>(null);
const locationInputRef = React.useRef<HTMLInputElement>(null);
// Track previous showMore state to detect changes
const prevShowMoreRef = React.useRef<boolean | undefined>(undefined);
// Focus location field when user clicks the location preview row
React.useEffect(() => {
if (hasClickedLocationSection && process.env.NODE_ENV !== "test") {
locationInputRef.current?.focus();
}
}, [hasClickedLocationSection]);
// Auto-focus title field when modal opens (skip in test environment)
React.useEffect(() => {
if (isOpen) {
@@ -389,6 +421,9 @@ export default function EventFormFields({
setDescription(updatedDescription);
setHasVideoConference(true);
setMeetingLink(newMeetingLink);
if (showMore) {
setShowDescription(true);
}
};
const [openToast, setOpenToast] = React.useState(false);
@@ -405,11 +440,7 @@ export default function EventFormFields({
};
const handleDeleteVideoConference = () => {
const updatedDescription = description.replace(
/\nVisio: https?:\/\/[^\s]+/,
""
);
setDescription(updatedDescription);
setDescription(removeVideoConferenceFromDescription(description));
setHasVideoConference(false);
setMeetingLink(null);
};
@@ -421,7 +452,10 @@ export default function EventFormFields({
return (
<>
<FieldWithLabel label={t("event.form.title")} isExpanded={showMore}>
<FieldWithLabel
label={showMore ? t("event.form.title") : ""}
isExpanded={showMore}
>
<TextField
fullWidth
label=""
@@ -437,156 +471,159 @@ export default function EventFormFields({
/>
</FieldWithLabel>
<AddDescButton
showDescription={showDescription}
setShowDescription={setShowDescription}
showMore={showMore}
description={description}
setDescription={setDescription}
buttonVariant="contained"
buttonColor="secondary"
/>
<FieldWithLabel label={t("event.form.dateTime")} isExpanded={showMore}>
<DateTimeFields
startDate={startDate}
startTime={startTime}
endDate={endDate}
endTime={endTime}
allday={allday}
showMore={showMore}
hasEndDateChanged={hasEndDateChanged}
validation={validation}
onStartDateChange={handleStartDateChange}
onStartTimeChange={handleStartTimeChange}
onEndDateChange={handleEndDateChange}
onEndTimeChange={handleEndTimeChange}
showEndDate={
showMore ||
allday ||
(hasEndDateChanged && startDate !== endDate) ||
(!showMore && !allday && startDate !== endDate)
}
onToggleEndDate={() => {}}
/>
</FieldWithLabel>
<FieldWithLabel label=" " isExpanded={showMore}>
<Box display="flex" gap={2} alignItems="center">
<FormControlLabel
control={
<Checkbox checked={allday} onChange={handleAllDayToggle} />
}
label={
<Typography variant="h6">{t("event.form.allDay")}</Typography>
}
/>
<FormControlLabel
control={
<Checkbox
checked={
showRepeat || (typeOfAction === "solo" && !!repetition?.freq)
}
disabled={typeOfAction === "solo"}
onChange={() => {
const newShowRepeat = !showRepeat;
setShowRepeat(newShowRepeat);
if (newShowRepeat) {
setRepetition({
freq: "daily",
interval: 1,
occurrences: 0,
endDate: "",
byday: null,
} as RepetitionObject);
} else {
setRepetition({
freq: "",
interval: 1,
occurrences: 0,
endDate: "",
byday: null,
} as RepetitionObject);
}
}}
/>
}
label={
<Typography variant="h6">{t("event.form.repeat")}</Typography>
}
/>
<TimezoneAutocomplete
value={timezone}
onChange={setTimezone}
zones={timezoneList.zones}
getTimezoneOffset={(tzName: string) =>
timezoneList.getTimezoneOffset(tzName, new Date(start))
}
showIcon={false}
width={240}
size="small"
placeholder={t("event.form.timezonePlaceholder")}
/>
</Box>
</FieldWithLabel>
{(showRepeat || (typeOfAction === "solo" && repetition?.freq)) && (
<FieldWithLabel label=" " isExpanded={showMore}>
<RepeatEvent
<FieldWithLabel
label={
!showMore && !hasClickedDateTimeSection
? ""
: t("event.form.dateTime")
}
isExpanded={showMore}
>
{!showMore && !hasClickedDateTimeSection ? (
<DateTimeSummary
startDate={startDate}
startTime={startTime}
endDate={endDate}
endTime={endTime}
allday={allday}
timezone={timezone}
repetition={repetition}
eventStart={new Date(start)}
setRepetition={setRepetition}
isOwn={typeOfAction !== "solo"}
showEndDate={
allday ||
(hasEndDateChanged && startDate !== endDate) ||
(!allday && startDate !== endDate)
}
onClick={() => setHasClickedDateTimeSection(true)}
/>
) : (
<DateTimeFields
startDate={startDate}
startTime={startTime}
endDate={endDate}
endTime={endTime}
allday={allday}
showMore={showMore}
hasEndDateChanged={hasEndDateChanged}
validation={validation}
onStartDateChange={handleStartDateChange}
onStartTimeChange={handleStartTimeChange}
onEndDateChange={handleEndDateChange}
onEndTimeChange={handleEndTimeChange}
showEndDate={
showMore ||
allday ||
(hasEndDateChanged && startDate !== endDate) ||
(!showMore && !allday && startDate !== endDate)
}
onToggleEndDate={() => {}}
/>
)}
</FieldWithLabel>
{!(!showMore && !hasClickedDateTimeSection) && (
<FieldWithLabel label=" " isExpanded={showMore}>
<Box display="flex" gap={2} alignItems="center">
<FormControlLabel
control={
<Checkbox checked={allday} onChange={handleAllDayToggle} />
}
label={
<Typography variant="h6">{t("event.form.allDay")}</Typography>
}
/>
<FormControlLabel
control={
<Checkbox
checked={
showRepeat ||
(typeOfAction === "solo" && !!repetition?.freq)
}
disabled={typeOfAction === "solo"}
onChange={() => {
const newShowRepeat = !showRepeat;
setShowRepeat(newShowRepeat);
if (newShowRepeat) {
setRepetition({
freq: "daily",
interval: 1,
occurrences: 0,
endDate: "",
byday: null,
} as RepetitionObject);
} else {
setRepetition({
freq: "",
interval: 1,
occurrences: 0,
endDate: "",
byday: null,
} as RepetitionObject);
}
}}
/>
}
label={
<Typography variant="h6">{t("event.form.repeat")}</Typography>
}
/>
<TimezoneAutocomplete
value={timezone}
onChange={setTimezone}
zones={timezoneList.zones}
getTimezoneOffset={(tzName: string) =>
timezoneList.getTimezoneOffset(tzName, new Date(start))
}
showIcon={false}
width={220}
size="small"
placeholder={t("event.form.timezonePlaceholder")}
/>
</Box>
</FieldWithLabel>
)}
{!(!showMore && !hasClickedDateTimeSection) &&
(showRepeat || (typeOfAction === "solo" && repetition?.freq)) && (
<FieldWithLabel label=" " isExpanded={showMore}>
<RepeatEvent
repetition={repetition}
eventStart={new Date(start)}
setRepetition={setRepetition}
isOwn={typeOfAction !== "solo"}
/>
</FieldWithLabel>
)}
<FieldWithLabel
label={t("event.form.participants")}
label={showMore ? t("event.form.participants") : ""}
isExpanded={showMore}
>
<AttendeeSelector
attendees={attendees}
setAttendees={setAttendees}
placeholder={t("event.form.addGuestsPlaceholder")}
inputSlot={(params) => <TextField {...params} size="small" />}
/>
</FieldWithLabel>
<FieldWithLabel
label={t("event.form.videoMeeting")}
label={showMore ? t("event.form.videoMeeting") : ""}
isExpanded={showMore}
>
<Box display="flex" gap={1} alignItems="center">
<Button
startIcon={
<img src={iconCamera} alt="camera" width={24} height={24} />
}
onClick={handleAddVideoConference}
size="medium"
variant="contained"
color="secondary"
sx={{
borderRadius: "4px",
display: hasVideoConference ? "none" : "flex",
}}
>
{t("event.form.addVisioConference")}
</Button>
{hasVideoConference && meetingLink && (
<>
{!showMore ? (
hasVideoConference && meetingLink ? (
<Box display="flex" gap={1} alignItems="center">
<Button
startIcon={
<img src={iconCamera} alt="camera" width={24} height={24} />
}
onClick={() => window.open(meetingLink, "_blank")}
onClick={() =>
window.open(meetingLink, "_blank", "noopener,noreferrer")
}
size="medium"
variant="contained"
color="primary"
sx={{
borderRadius: "4px",
mr: 1,
}}
sx={{ borderRadius: "4px", mr: 1 }}
>
{t("event.form.joinVisioConference")}
</Button>
@@ -608,38 +645,153 @@ export default function EventFormFields({
>
<DeleteIcon />
</IconButton>
</>
)}
</Box>
</Box>
) : (
<SectionPreviewRow
icon={
<img src={iconCamera} alt="camera" width={24} height={24} />
}
onClick={handleAddVideoConference}
>
{t("event.form.addVisioConference")}
</SectionPreviewRow>
)
) : (
<Box display="flex" gap={1} alignItems="center">
<Button
startIcon={
<img src={iconCamera} alt="camera" width={24} height={24} />
}
onClick={handleAddVideoConference}
size="medium"
variant="contained"
color="secondary"
sx={{
borderRadius: "4px",
display: hasVideoConference ? "none" : "flex",
}}
>
{t("event.form.addVisioConference")}
</Button>
{hasVideoConference && meetingLink && (
<>
<Button
startIcon={
<img src={iconCamera} alt="camera" width={24} height={24} />
}
onClick={() =>
window.open(meetingLink, "_blank", "noopener,noreferrer")
}
size="medium"
variant="contained"
color="primary"
sx={{
borderRadius: "4px",
mr: 1,
}}
>
{t("event.form.joinVisioConference")}
</Button>
<IconButton
onClick={handleCopyMeetingLink}
size="small"
sx={{ color: "primary.main" }}
aria-label={t("event.form.copyMeetingLink")}
title={t("event.form.copyMeetingLink")}
>
<CopyIcon />
</IconButton>
<IconButton
onClick={handleDeleteVideoConference}
size="small"
sx={{ color: "error.main" }}
aria-label={t("event.form.removeVideoConference")}
title={t("event.form.removeVideoConference")}
>
<DeleteIcon />
</IconButton>
</>
)}
</Box>
)}
</FieldWithLabel>
<FieldWithLabel label={t("event.form.location")} isExpanded={showMore}>
<TextField
fullWidth
label=""
inputProps={{ "aria-label": t("event.form.location") }}
placeholder={t("event.form.locationPlaceholder")}
value={location}
onChange={(e) => setLocation(e.target.value)}
size="small"
margin="dense"
/>
</FieldWithLabel>
<AddDescButton
showDescription={showDescription}
setShowDescription={setShowDescription}
showMore={showMore}
description={description}
setDescription={setDescription}
/>
<FieldWithLabel label={t("event.form.calendar")} isExpanded={showMore}>
<FormControl fullWidth margin="dense" size="small">
<Select
value={calendarid ?? ""}
label=""
SelectDisplayProps={{ "aria-label": t("event.form.calendar") }}
displayEmpty
onChange={(e: SelectChangeEvent) =>
handleCalendarChange(e.target.value)
}
<FieldWithLabel
label={
showMore || hasClickedLocationSection ? t("event.form.location") : ""
}
isExpanded={showMore}
>
{!showMore && !hasClickedLocationSection ? (
<SectionPreviewRow
icon={<LocationIcon />}
onClick={() => setHasClickedLocationSection(true)}
>
{CalendarItemList(userPersonalCalendars)}
</Select>
</FormControl>
{location || t("event.form.locationPlaceholder")}
</SectionPreviewRow>
) : (
<TextField
fullWidth
label=""
inputRef={locationInputRef}
inputProps={{ "aria-label": t("event.form.location") }}
placeholder={t("event.form.locationPlaceholder")}
value={location}
onChange={(e) => setLocation(e.target.value)}
size="small"
margin="dense"
/>
)}
</FieldWithLabel>
<FieldWithLabel
label={
showMore || hasClickedCalendarSection ? t("event.form.calendar") : ""
}
isExpanded={showMore}
>
{!showMore && !hasClickedCalendarSection ? (
<SectionPreviewRow
icon={
<SquareRoundedIcon
sx={{
color:
userPersonalCalendars.find((cal) => cal.id === calendarid)
?.color?.light ?? "#3788D8",
width: 24,
height: 24,
}}
/>
}
onClick={() => setHasClickedCalendarSection(true)}
>
{userPersonalCalendars.find((cal) => cal.id === calendarid)?.name ||
t("event.form.calendar")}
</SectionPreviewRow>
) : (
<FormControl fullWidth margin="dense" size="small">
<Select
value={calendarid ?? ""}
label=""
SelectDisplayProps={{ "aria-label": t("event.form.calendar") }}
displayEmpty
onChange={(e: SelectChangeEvent) =>
handleCalendarChange(e.target.value)
}
>
{CalendarItemList(userPersonalCalendars)}
</Select>
</FormControl>
)}
</FieldWithLabel>
{showMore && (
+3 -1
View File
@@ -8,6 +8,7 @@ type InfoRowProps = {
data?: string; // optional link target
content?: React.ReactNode; // if provided, overrides text rendering
style?: React.CSSProperties;
alignItems?: React.CSSProperties["alignItems"];
};
function detectUrls(text: string) {
@@ -62,12 +63,13 @@ export function InfoRow({
data,
content,
style,
alignItems = "center",
}: InfoRowProps) {
return (
<Box
style={{
display: "flex",
alignItems: "center",
alignItems,
gap: 1,
marginBottom: 1,
}}
@@ -0,0 +1,87 @@
import { Box, Typography, useTheme } from "@linagora/twake-mui";
import { alpha } from "@mui/material/styles";
import React from "react";
interface ClickableFieldProps {
icon: React.ReactNode;
text?: string;
onClick: () => void;
iconColor?: string;
children?: React.ReactNode;
ariaLabel?: string;
}
export const ClickableField: React.FC<ClickableFieldProps> = ({
icon,
text,
onClick,
iconColor,
children,
ariaLabel,
}) => {
const theme = useTheme();
return (
<Box
onClick={onClick}
role="button"
tabIndex={0}
aria-label={ariaLabel ?? text}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
sx={{
display: "flex",
alignItems: children ? "flex-start" : "center",
cursor: "pointer",
padding: "8px 12px",
borderRadius: "4px",
"&:hover": {
backgroundColor: "action.hover",
},
"&:focus-visible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: "2px",
},
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
maxWidth: "24px",
maxHeight: "24px",
marginRight: "12px",
color: iconColor || alpha(theme.palette.grey[900], 0.9),
"& svg": {
width: "24px",
height: "24px",
},
"& img": {
width: "24px",
height: "24px",
},
}}
>
{icon}
</Box>
{children ? (
<Box flex={1}>{children}</Box>
) : (
<Typography
sx={{
fontSize: "14px",
color: alpha(theme.palette.grey[900], 0.9),
fontWeight: 500,
}}
>
{text}
</Typography>
)}
</Box>
);
};
@@ -37,6 +37,15 @@ const timePickerPopperSx = {
},
};
// twake-mui datePickerOverrides also uses this selector. Repeating ensures our override wins.
const dateCalendarLayoutSx = {
"& .MuiDateCalendar-root.MuiDateCalendar-root": {
width: "260px",
maxWidth: "260px",
padding: "0 15px",
},
};
/**
* Props for DateTimeFields component
*/
@@ -369,6 +378,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
false,
t("dateTimeFields.startDate")
),
layout: { sx: dateCalendarLayoutSx },
}}
/>
</Box>
@@ -415,6 +425,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
!!validation.errors.dateTime,
t("dateTimeFields.endDate")
),
layout: { sx: dateCalendarLayoutSx },
}}
/>
</Box>
@@ -463,6 +474,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
false,
t("dateTimeFields.startDate")
),
layout: { sx: dateCalendarLayoutSx },
}}
/>
</Box>
@@ -483,6 +495,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
!!validation.errors.dateTime,
t("dateTimeFields.endDate")
),
layout: { sx: dateCalendarLayoutSx },
}}
/>
</Box>
@@ -502,6 +515,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
false,
startDateLabel
),
layout: { sx: dateCalendarLayoutSx },
}}
/>
</Box>
@@ -0,0 +1,171 @@
import { Box, Typography, useTheme } from "@linagora/twake-mui";
import { alpha } from "@mui/material/styles";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import dayjs from "dayjs";
import "dayjs/locale/en";
import "dayjs/locale/fr";
import "dayjs/locale/ru";
import "dayjs/locale/vi";
import React from "react";
import { useI18n } from "twake-i18n";
import { getTimezoneOffset } from "@/utils/timezone";
import { RepetitionObject } from "@/features/Events/EventsTypes";
import { LONG_DATE_FORMAT } from "../utils/dateTimeFormatters";
import { SectionPreviewRow } from "./SectionPreviewRow";
interface DateTimeSummaryProps {
startDate: string;
startTime: string;
endDate: string;
endTime: string;
allday: boolean;
timezone: string;
repetition: RepetitionObject;
showEndDate: boolean;
onClick: () => void;
}
export const DateTimeSummary: React.FC<DateTimeSummaryProps> = ({
startDate,
startTime,
endDate,
endTime,
allday,
timezone,
repetition,
showEndDate,
onClick,
}) => {
const { t, lang } = useI18n();
const theme = useTheme();
// Format date with current locale. VI: "Thứ 4, 4 Tháng 2, 2026"; FR: "mercredi, 5 février 2026"; RU: first letter capitalized
const formatDate = (dateStr: string): string => {
if (!dateStr) return "";
const date = dayjs(dateStr);
const locale =
lang && ["en", "vi", "fr", "ru"].includes(lang) ? lang : "en";
if (locale === "vi") {
const dow = date.day(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
const weekdayLabel = dow === 0 ? "Chủ nhật" : `Thứ ${dow + 1}`; // Mon=Thứ 2, Wed=Thứ 4, ...
const day = date.date();
const month = date.month() + 1;
const year = date.year();
return `${weekdayLabel}, ${day} Tháng ${month}, ${year}`;
}
// French: "5 février" (day before month), not "février 5"
if (locale === "fr") {
const formatted = date.locale("fr").format("dddd, D MMMM YYYY");
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
}
const formatted = date.locale(locale).format(LONG_DATE_FORMAT);
if (locale === "ru") {
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
}
return formatted;
};
// Format time in 24h: "03:30 - 16:30"
const formatTime = (startTimeStr: string, endTimeStr: string): string => {
if (allday || !startTimeStr || !endTimeStr) return "";
const toHHmm = (timeStr: string): string => {
const [h, m] = timeStr.split(":").map((s) => parseInt(s, 10) || 0);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
};
return `${toHHmm(startTimeStr)} - ${toHHmm(endTimeStr)}`;
};
// Format timezone: "(UTC+2) Paris". Use event date for offset (DST correctness).
const formatTimezone = (tz: string, dateStr?: string): string => {
if (!tz) return "";
try {
const dateForOffset = dateStr ? dayjs(dateStr).toDate() : new Date();
const offset = getTimezoneOffset(tz, dateForOffset);
const tzName = tz.replace(/_/g, " ");
return `(${offset}) ${tzName}`;
} catch (error) {
return tz.replace(/_/g, " ");
}
};
// Format repeat: "Doesn't repeat" or repeat info
const formatRepeat = (rep: RepetitionObject): string => {
if (!rep || !rep.freq) {
return t("event.repeat.doesNotRepeat");
}
const interval = rep.interval || 1;
const freqMap: { [key: string]: string } = {
daily: t("event.repeat.frequency.days"),
weekly: t("event.repeat.frequency.weeks"),
monthly: t("event.repeat.frequency.months"),
yearly: t("event.repeat.frequency.years"),
};
const freqText = freqMap[rep.freq] || rep.freq;
if (interval === 1) {
return `${t("event.repeat.every")} ${freqText}`;
}
return `${t("event.repeat.every")} ${interval} ${freqText}`;
};
// Format date text: show both start and end date if showEndDate is true
const formatDateText = (): string => {
if (showEndDate && endDate && endDate !== startDate) {
const startDateText = formatDate(startDate);
const endDateText = formatDate(endDate);
return `${startDateText} - ${endDateText}`;
}
return formatDate(startDate);
};
const dateText = formatDateText();
const timeText = formatTime(startTime, endTime);
const timezoneText = formatTimezone(timezone, startDate);
const repeatText = formatRepeat(repetition);
// Don't render if no date
if (!startDate) {
return null;
}
const primaryStyle = {
fontSize: "14px",
fontWeight: 500,
color: alpha(theme.palette.grey[900], 0.9),
};
return (
<SectionPreviewRow
icon={<AccessTimeIcon sx={{ color: "text.secondary" }} />}
onClick={onClick}
>
<Box>
<Typography component="p" sx={primaryStyle}>
{dateText}
{showEndDate && <br />}
{timeText && (
<Box component="span" sx={{ ml: showEndDate ? 0 : 2 }}>
{timeText}
</Box>
)}
</Typography>
<Box display="flex" gap={2} alignItems="center" mt={0.5}>
<Typography variant="caption" sx={{ color: "#444746" }}>
{timezoneText}
</Typography>
<Typography variant="caption" sx={{ color: "#444746" }}>
{repeatText}
</Typography>
</Box>
</Box>
</SectionPreviewRow>
);
};
@@ -0,0 +1,89 @@
import { Box, Typography, useTheme } from "@linagora/twake-mui";
import { alpha } from "@mui/material/styles";
import React from "react";
export interface SectionPreviewRowProps {
icon: React.ReactNode;
/** Primary text or custom content. If string, rendered with fontSize 14px, fontWeight 500. */
children: React.ReactNode;
onClick: () => void;
iconColor?: string;
}
/**
* Reusable preview row for event modal sections (normal mode).
* Layout: icon left (max 32x32), content right. No button.
*/
export const SectionPreviewRow: React.FC<SectionPreviewRowProps> = ({
icon,
children,
onClick,
iconColor,
}) => {
const theme = useTheme();
const color = iconColor ?? alpha(theme.palette.grey[900], 0.9);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
};
return (
<Box
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
sx={{
display: "flex",
alignItems: "center",
cursor: "pointer",
padding: "8px 12px",
borderRadius: "4px",
"&:hover": {
backgroundColor: "action.hover",
},
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
maxWidth: "24px",
maxHeight: "24px",
marginRight: "12px",
flexShrink: 0,
color,
"& svg": {
maxWidth: "24px",
maxHeight: "24px",
},
"& img": {
maxWidth: "24px",
maxHeight: "24px",
},
}}
>
{icon}
</Box>
<Box flex={1} minWidth={0}>
{typeof children === "string" ? (
<Typography
sx={{
fontSize: "14px",
fontWeight: 500,
color,
}}
>
{children}
</Typography>
) : (
children
)}
</Box>
</Box>
);
};
+1 -10
View File
@@ -65,16 +65,7 @@ export function renderAttendeeBadge(
<Avatar {...stringAvatar(a.cn || a.cal_address)} />
</Badge>
<Box style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
<Typography
noWrap
style={{
maxWidth: "180px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{a.cn || a.cal_address}
</Typography>
<Typography noWrap>{a.cn || a.cal_address}</Typography>
{isOrganizer && (
<Typography variant="caption" color="text.secondary">
{t("event.organizer")}