Merge pull request #504 from linagora/UI/update-header-bar
UI/update header bar
This commit is contained in:
@@ -12,6 +12,7 @@ export default function UserSearch({
|
||||
setAttendees,
|
||||
disabled,
|
||||
inputSlot,
|
||||
placeholder,
|
||||
}: {
|
||||
attendees: userAttendee[];
|
||||
setAttendees: Function;
|
||||
@@ -19,8 +20,9 @@ export default function UserSearch({
|
||||
inputSlot?: (
|
||||
params: ExtendedAutocompleteRenderInputParams
|
||||
) => React.ReactNode;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [selectedUsers, setSelectedUsers] = useState(
|
||||
const [selectedUsers, setSelectedUsers] = useState<User[]>(
|
||||
attendees.map((attendee) => ({
|
||||
email: attendee.cal_address,
|
||||
displayName: attendee.cn ?? "",
|
||||
@@ -44,6 +46,7 @@ export default function UserSearch({
|
||||
objectTypes={["user", "contact"]}
|
||||
disabled={disabled}
|
||||
inputSlot={inputSlot}
|
||||
placeholder={placeholder}
|
||||
onChange={(event: any, value: User[]) => {
|
||||
setAttendees(
|
||||
value.map((attendee: User) =>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
.main-layout
|
||||
display flex
|
||||
height calc(100vh - 90px)
|
||||
height calc(100vh - 70px)
|
||||
|
||||
.main-layout.calendar-layout
|
||||
background-color #fff
|
||||
@@ -117,7 +117,7 @@
|
||||
margin-top 2px
|
||||
width 6px
|
||||
height 6px
|
||||
background-color #d93025
|
||||
background-color #1D192B1F
|
||||
border-radius 50%
|
||||
margin-left auto
|
||||
margin-right auto
|
||||
|
||||
@@ -73,7 +73,7 @@ span.fc-daygrid-day-number.current-date
|
||||
color #fff
|
||||
|
||||
span.fc-daygrid-day-number.current-date:after
|
||||
background-color #f67e35
|
||||
background-color #FB9E3A
|
||||
|
||||
td.fc-timegrid-slot.fc-timegrid-slot-label.fc-scrollgrid-shrink,
|
||||
td.fc-timegrid-slot.fc-timegrid-slot-label.fc-timegrid-slot-minor
|
||||
@@ -244,7 +244,7 @@ tbody > tr.fc-scrollgrid-section:first-of-type .fc-scroller
|
||||
overflow hidden !important
|
||||
|
||||
.navigation-controls
|
||||
margin-right 40px
|
||||
margin-right 16px
|
||||
|
||||
.fc .fc-timegrid-slot-minor
|
||||
border-top-style: none
|
||||
|
||||
@@ -50,21 +50,26 @@ export function SettingsTab({
|
||||
<>
|
||||
{/* Form group 1: Name field - first group, margin top 0 */}
|
||||
<Box mt={0}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label=""
|
||||
inputProps={{ "aria-label": t("common.name") }}
|
||||
placeholder={t("common.name")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
size="small"
|
||||
sx={{
|
||||
"&.MuiFormControl-root": {
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ margin: 0 }}>
|
||||
{t("calendarPopover.settings.calendarName")}
|
||||
</Typography>
|
||||
<Box sx={{ marginTop: "6px" }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label=""
|
||||
inputProps={{ "aria-label": t("common.name") }}
|
||||
placeholder={t("common.name")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
size="small"
|
||||
sx={{
|
||||
"&.MuiFormControl-root": {
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Form group 2: Description */}
|
||||
@@ -75,8 +80,6 @@ export function SettingsTab({
|
||||
showMore={false}
|
||||
description={description}
|
||||
setDescription={setDescription}
|
||||
buttonVariant="contained"
|
||||
buttonColor="secondary"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ A highly reusable dialog component that supports both normal and expanded (fulls
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Two Modes**: Normal popup (685px) and expanded fullscreen mode
|
||||
- ✅ **Preserves Header**: Expanded mode doesn't cover app header (90px default)
|
||||
- ✅ **Two Modes**: Normal popup (570px) and expanded fullscreen mode
|
||||
- ✅ **Preserves Header**: Expanded mode doesn't cover app header (70px default)
|
||||
- ✅ **Clean Expanded View**: No backdrop/shadow in expanded mode for seamless integration
|
||||
- ✅ **Instant Transition**: No animation when expanding for immediate feedback
|
||||
- ✅ **Back Navigation**: Expanded mode shows back arrow icon in header for easy collapse
|
||||
@@ -62,9 +62,9 @@ function MyComponent() {
|
||||
| `actions` | `ReactNode` | - | Action buttons |
|
||||
| `isExpanded` | `boolean` | `false` | Toggle fullscreen mode |
|
||||
| `onExpandToggle` | `() => void` | - | Handler for back button click when expanded |
|
||||
| `normalMaxWidth` | `string` | `"685px"` | Max width in normal mode |
|
||||
| `normalMaxWidth` | `string` | `"570px"` | Max width in normal mode |
|
||||
| `expandedContentMaxWidth` | `string` | `"990px"` | Content container max-width in expanded mode |
|
||||
| `headerHeight` | `string` | `"90px"` | App header height to preserve |
|
||||
| `headerHeight` | `string` | `"70px"` | App header height to preserve |
|
||||
| `normalSpacing` | `number` | `2` | Stack spacing in normal mode (MUI spacing units: 1 = 8px) |
|
||||
| `expandedSpacing` | `number` | `2` | Stack spacing in expanded mode (MUI spacing units: 1 = 8px) |
|
||||
| `contentSx` | `SxProps<Theme>` | - | Custom styles for DialogContent |
|
||||
@@ -149,7 +149,7 @@ For apps with different header heights:
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Custom Header Height"
|
||||
headerHeight="80px"
|
||||
headerHeight="70px"
|
||||
isExpanded={true}
|
||||
>
|
||||
<TextField label="Content" />
|
||||
@@ -160,7 +160,7 @@ For apps with different header heights:
|
||||
|
||||
### Normal Mode (`isExpanded={false}`)
|
||||
|
||||
- Dialog: max-width = `normalMaxWidth` (default 685px)
|
||||
- Dialog: max-width = `normalMaxWidth` (default 570px)
|
||||
- Content: 100% width
|
||||
- Height: auto (fits content)
|
||||
- Position: centered with 32px margin
|
||||
@@ -215,7 +215,7 @@ import { ResponsiveDialog } from "@/components/Dialog";
|
||||
1. **Use `isExpanded` for Show More/Less**: Toggle between modes for better UX
|
||||
2. **Provide `onExpandToggle` handler**: Required for back button functionality in expanded mode
|
||||
3. **Hide expand button in actions when expanded**: Back arrow in header provides collapse action
|
||||
4. **Keep `normalMaxWidth` reasonable**: 685px works well for forms
|
||||
4. **Keep `normalMaxWidth` reasonable**: 570px works well for forms
|
||||
5. **Center content in expanded mode**: Default 990px provides good reading width
|
||||
6. **Preserve header**: Default 90px - adjust via `headerHeight` prop if needed
|
||||
7. **MUI Stack handles spacing**: Children wrapped in Stack with configurable spacing prop - override with `normalSpacing`/`expandedSpacing` if needed
|
||||
|
||||
@@ -21,7 +21,7 @@ import React, { ReactNode } from "react";
|
||||
* ResponsiveDialog - A reusable dialog component that can switch between normal and expanded modes
|
||||
*
|
||||
* Features:
|
||||
* - Normal mode: Dialog with customizable max-width (default 685px)
|
||||
* - Normal mode: Dialog with customizable max-width (default 570px)
|
||||
* - Expanded mode: Full height dialog (excluding app header) with centered content container
|
||||
* - Fully customizable with sx props for Dialog, DialogTitle, and DialogContent
|
||||
* - Preserves app header visibility in expanded mode
|
||||
@@ -58,7 +58,7 @@ interface ResponsiveDialogProps extends Omit<
|
||||
isExpanded?: boolean;
|
||||
/** Callback when expand/collapse button is clicked (required if using isExpanded) */
|
||||
onExpandToggle?: () => void;
|
||||
/** Max width in normal mode (default: "685px") */
|
||||
/** Max width in normal mode (default: "570px") */
|
||||
normalMaxWidth?: string;
|
||||
/** Max width of content container in expanded mode (default: "990px") */
|
||||
expandedContentMaxWidth?: string;
|
||||
@@ -98,9 +98,9 @@ function ResponsiveDialog({
|
||||
actions,
|
||||
isExpanded = false,
|
||||
onExpandToggle,
|
||||
normalMaxWidth = "685px",
|
||||
normalMaxWidth = "570px",
|
||||
expandedContentMaxWidth = "990px",
|
||||
headerHeight = "90px",
|
||||
headerHeight = "70px",
|
||||
normalSpacing = 2,
|
||||
expandedSpacing = 2,
|
||||
contentSx,
|
||||
@@ -116,6 +116,7 @@ function ResponsiveDialog({
|
||||
}: ResponsiveDialogProps) {
|
||||
const baseSx: SxProps<Theme> = {
|
||||
"& .MuiBackdrop-root": {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
||||
opacity: isExpanded ? "0 !important" : undefined,
|
||||
transition: isExpanded ? "none !important" : undefined,
|
||||
pointerEvents: isExpanded ? "none" : undefined,
|
||||
@@ -202,8 +203,9 @@ function ResponsiveDialog({
|
||||
onClick={onExpandToggle}
|
||||
aria-label="expand"
|
||||
size="small"
|
||||
sx={{ marginRight: 1 }}
|
||||
>
|
||||
<OpenInFullIcon />
|
||||
<OpenInFullIcon sx={{ padding: "2px" }} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={onClose} aria-label="close" size="small">
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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")}
|
||||
|
||||
@@ -214,7 +214,7 @@ export default function SearchBar() {
|
||||
}}
|
||||
>
|
||||
{!extended && (
|
||||
<IconButton onClick={() => setExtended(true)}>
|
||||
<IconButton sx={{ mr: 1 }} onClick={() => setExtended(true)}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
@@ -38,20 +38,22 @@
|
||||
padding 0.5rem
|
||||
width 100%
|
||||
z-index 1300
|
||||
height 80px
|
||||
height 60px
|
||||
background-color #fff
|
||||
|
||||
.menubar-item
|
||||
display flex
|
||||
align-items center
|
||||
width calc(330px - 0.5rem)
|
||||
margin-right 65px
|
||||
|
||||
.tc-home
|
||||
cursor pointer
|
||||
|
||||
.logo
|
||||
padding 0.5rem 1rem
|
||||
padding 0
|
||||
font-size 1.5rem
|
||||
max-width 230px
|
||||
margin-left 1rem
|
||||
|
||||
.nav-month
|
||||
padding-right 2px
|
||||
@@ -91,6 +93,14 @@
|
||||
justify-content flex-end
|
||||
align-items center
|
||||
|
||||
.current-date-time
|
||||
font-family Roboto
|
||||
font-size 22px
|
||||
font-style normal
|
||||
font-weight 400
|
||||
line-height 36px
|
||||
color #243B55
|
||||
|
||||
body.fullscreen-view .navigation-controls,
|
||||
body.fullscreen-view .current-date-time,
|
||||
body.fullscreen-view .refresh-button,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Logout } from "@/features/User/oidcAuth";
|
||||
import logo from "@/static/header-logo.svg";
|
||||
import { getInitials, stringToGradient } from "@/utils/avatarUtils";
|
||||
import { getUserDisplayName } from "@/utils/userUtils";
|
||||
import { redirectTo } from "@/utils/navigation";
|
||||
import { CalendarApi } from "@fullcalendar/core";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -19,7 +20,8 @@ import {
|
||||
Select,
|
||||
Typography,
|
||||
} from "@linagora/twake-mui";
|
||||
import AppsIcon from "@mui/icons-material/Apps";
|
||||
import WidgetsOutlinedIcon from "@mui/icons-material/WidgetsOutlined";
|
||||
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import LogoutIcon from "@mui/icons-material/Logout";
|
||||
@@ -139,7 +141,7 @@ export function Menubar({
|
||||
const handleLogoutClick = async () => {
|
||||
const logoutUrl = await Logout();
|
||||
sessionStorage.removeItem("tokenSet");
|
||||
window.location.assign(logoutUrl.href);
|
||||
redirectTo(logoutUrl.href);
|
||||
handleUserMenuClose();
|
||||
};
|
||||
|
||||
@@ -195,9 +197,7 @@ export function Menubar({
|
||||
</div>
|
||||
<div className="menu-items">
|
||||
<div className="current-date-time">
|
||||
<Typography variant="h3" component="div">
|
||||
{dateLabel}
|
||||
</Typography>
|
||||
<p>{dateLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,6 +211,7 @@ export function Menubar({
|
||||
onClick={onRefresh}
|
||||
aria-label={t("menubar.refresh")}
|
||||
title={t("menubar.refresh")}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
@@ -226,7 +227,11 @@ export function Menubar({
|
||||
onChange={(e) => handleViewChange(e.target.value)}
|
||||
variant="outlined"
|
||||
aria-label={t("menubar.viewSelector")}
|
||||
sx={{ height: "43px" }}
|
||||
sx={{
|
||||
borderRadius: "12px",
|
||||
marginLeft: 1,
|
||||
"& fieldset": { borderRadius: "12px" },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="dayGridMonth">
|
||||
{t("menubar.views.month")}
|
||||
@@ -240,6 +245,19 @@ export function Menubar({
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className="menu-items">
|
||||
<IconButton
|
||||
component="a"
|
||||
href="https://twake.app/support/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginRight: 8 }}
|
||||
aria-label={t("menubar.help")}
|
||||
title={t("menubar.help")}
|
||||
>
|
||||
<HelpOutlineIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="menu-items">
|
||||
{applist.length > 0 && (
|
||||
<IconButton
|
||||
@@ -248,7 +266,7 @@ export function Menubar({
|
||||
aria-label={t("menubar.apps")}
|
||||
title={t("menubar.apps")}
|
||||
>
|
||||
<AppsIcon />
|
||||
<WidgetsOutlinedIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user