Compare commits
10 Commits
234b4c3ae8
...
faefd31ac3
| Author | SHA1 | Date | |
|---|---|---|---|
| faefd31ac3 | |||
| 0089fba30d | |||
| fe15fc076a | |||
| d16e7941ad | |||
| 1ceaefa7bc | |||
| c429306e77 | |||
| d66bc59967 | |||
| 29d82e5ade | |||
| 2f849b5323 | |||
| c910f3e3a9 |
+1
-1
@@ -18,7 +18,7 @@
|
||||
-->
|
||||
<link rel="stylesheet" href="<%= assetPrefix %>/fonts/fonts.css" />
|
||||
|
||||
<title>Twake Calendar</title>
|
||||
<title>Workavia Calendar</title>
|
||||
<script src="<%= assetPrefix %>/.env.js"></script>
|
||||
<script src="<%= assetPrefix %>/appList.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"short_name": "Twake Calendar",
|
||||
"name": "Twake Calendar App",
|
||||
"short_name": "Workavia Calendar",
|
||||
"name": "Workavia Calendar",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
|
||||
@@ -301,7 +301,8 @@ export const PeopleSearch: React.FC<PeopleSearchProps> = ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: 'row'
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
maxWidth: '80%'
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
margin-left 25%
|
||||
|
||||
.sidebar
|
||||
border-right 2px solid #f3f4f6
|
||||
width 270px
|
||||
height 100%
|
||||
flex-direction column
|
||||
|
||||
@@ -416,6 +416,10 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
||||
<div className="calendar">
|
||||
<ImportAlert />
|
||||
{menubarProps?.isIframe && <Menubar {...menubarProps} />}
|
||||
|
||||
{view === 'calendar' && (
|
||||
<>
|
||||
{' '}
|
||||
{(isTablet || isMobile) && (
|
||||
<Fab
|
||||
color="primary"
|
||||
@@ -432,7 +436,6 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
||||
<AddIcon />
|
||||
</Fab>
|
||||
)}
|
||||
{view === 'calendar' && (
|
||||
<div
|
||||
ref={calendarWrapperRef}
|
||||
className={isTablet || isMobile ? 'calendar-swipe-container' : ''}
|
||||
@@ -445,9 +448,7 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
||||
<FullCalendar
|
||||
key={hiddenDays.join(',')}
|
||||
ref={ref => {
|
||||
if (ref) {
|
||||
calendarRef.current = ref.getApi()
|
||||
}
|
||||
calendarRef.current = ref ? ref.getApi() : null
|
||||
}}
|
||||
plugins={[
|
||||
dayGridPlugin,
|
||||
@@ -622,6 +623,7 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
||||
eventDidMount={viewHandlers.handleEventDidMount}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{view === 'search' && <SearchResultsPage />}
|
||||
<EventPopover
|
||||
|
||||
@@ -121,7 +121,9 @@ export default function CalendarLayout(): JSX.Element {
|
||||
currentView={currentView}
|
||||
/>
|
||||
)}
|
||||
{view === 'settings' && <SettingsPage isInIframe={isInIframe} />}
|
||||
{view === 'settings' && (
|
||||
<SettingsPage menubarProps={menubarProps} isInIframe={isInIframe} />
|
||||
)}
|
||||
<ErrorSnackbar error={error} type="calendar" />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,34 +10,23 @@ import {
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Checkbox,
|
||||
Divider,
|
||||
IconButton,
|
||||
ListItem,
|
||||
Menu,
|
||||
MenuItem
|
||||
ListItem
|
||||
} from '@linagora/twake-mui'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||
import MoreHorizIcon from '@mui/icons-material/MoreHoriz'
|
||||
import { SetStateAction, useEffect, useMemo, useState } from 'react'
|
||||
import { SetStateAction, useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { useI18n } from 'twake-i18n'
|
||||
import { useScreenSizeDetection } from '@/useScreenSizeDetection'
|
||||
import CalendarPopover from './CalendarModal'
|
||||
import CalendarSearch from './CalendarSearch'
|
||||
import { DeleteCalendarDialog } from './DeleteCalendarDialog'
|
||||
import { OwnerCaption } from './OwnerCaption'
|
||||
import CalendarResources from './CalendarResources'
|
||||
import { CalendarSelectorMenu } from './CalendarSelectorMenu'
|
||||
|
||||
function CalendarAccordion({
|
||||
title,
|
||||
calendars,
|
||||
selectedCalendars,
|
||||
handleToggle,
|
||||
showAddButton = false,
|
||||
onAddClick,
|
||||
defaultExpanded = false,
|
||||
setOpen,
|
||||
hideOwner
|
||||
}: {
|
||||
const CalendarAccordion: React.FC<{
|
||||
title: string
|
||||
calendars: string[]
|
||||
selectedCalendars: string[]
|
||||
@@ -47,14 +36,24 @@ function CalendarAccordion({
|
||||
defaultExpanded?: boolean
|
||||
setOpen: (id: string) => void
|
||||
hideOwner?: boolean
|
||||
}) {
|
||||
}> = ({
|
||||
title,
|
||||
calendars,
|
||||
selectedCalendars,
|
||||
handleToggle,
|
||||
showAddButton = false,
|
||||
onAddClick,
|
||||
defaultExpanded = false,
|
||||
setOpen,
|
||||
hideOwner
|
||||
}) => {
|
||||
const allCalendars = useAppSelector(state => state.calendars.list)
|
||||
const { t } = useI18n()
|
||||
|
||||
const [expended, setExpended] = useState(defaultExpanded)
|
||||
|
||||
useEffect(() => {
|
||||
const handleExpendedChange = () => {
|
||||
const handleExpendedChange = (): void => {
|
||||
setExpended(defaultExpanded)
|
||||
}
|
||||
handleExpendedChange()
|
||||
@@ -127,13 +126,10 @@ function CalendarAccordion({
|
||||
)
|
||||
}
|
||||
|
||||
export default function CalendarSelection({
|
||||
selectedCalendars,
|
||||
setSelectedCalendars
|
||||
}: {
|
||||
const CalendarSelection: React.FC<{
|
||||
selectedCalendars: string[]
|
||||
setSelectedCalendars: (value: SetStateAction<string[]>) => void
|
||||
}) {
|
||||
}> = ({ selectedCalendars, setSelectedCalendars }) => {
|
||||
const { t } = useI18n()
|
||||
const userId = useAppSelector(state => state.user.userData?.openpaasId) ?? ''
|
||||
const calendars = useAppSelector(state => state.calendars.list)
|
||||
@@ -158,7 +154,7 @@ export default function CalendarSelection({
|
||||
extractEventBaseUuid(id) !== userId && calendars?.[id]?.owner?.resource
|
||||
)
|
||||
|
||||
const handleCalendarToggle = (name: string) => {
|
||||
const handleCalendarToggle = (name: string): void => {
|
||||
setSelectedCalendars((prev: string[]) =>
|
||||
prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name]
|
||||
)
|
||||
@@ -243,7 +239,7 @@ export default function CalendarSelection({
|
||||
/>
|
||||
<CalendarSearch
|
||||
open={Boolean(anchorElCalOthers)}
|
||||
onClose={(newCalIds?: string[]) => {
|
||||
onClose={(newCalIds?: string[] | Record<string, never>) => {
|
||||
setAnchorElCalOthers(null)
|
||||
if (newCalIds?.length) {
|
||||
newCalIds.forEach(id => handleCalendarToggle(id))
|
||||
@@ -265,15 +261,7 @@ export default function CalendarSelection({
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarSelector({
|
||||
calendars,
|
||||
id,
|
||||
isPersonal,
|
||||
selectedCalendars,
|
||||
handleCalendarToggle,
|
||||
setOpen,
|
||||
hideOwner
|
||||
}: {
|
||||
const CalendarSelector: React.FC<{
|
||||
calendars: Record<string, Calendar>
|
||||
id: string
|
||||
isPersonal: boolean
|
||||
@@ -281,25 +269,62 @@ function CalendarSelector({
|
||||
handleCalendarToggle: (name: string) => void
|
||||
setOpen: () => void
|
||||
hideOwner?: boolean
|
||||
}) {
|
||||
}> = ({
|
||||
calendars,
|
||||
id,
|
||||
isPersonal,
|
||||
selectedCalendars,
|
||||
handleCalendarToggle,
|
||||
setOpen,
|
||||
hideOwner
|
||||
}) => {
|
||||
const { t } = useI18n()
|
||||
const dispatch = useAppDispatch()
|
||||
const calLink = useAppSelector(state => state.calendars.list[id].link) ?? ''
|
||||
const { isTooSmall: isMobile } = useScreenSizeDetection()
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||
const open = Boolean(anchorEl)
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
setAnchorEl(event.currentTarget)
|
||||
}
|
||||
const handleClose = () => {
|
||||
const handleClose = (): void => {
|
||||
setAnchorEl(null)
|
||||
}
|
||||
|
||||
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isLongPressedRef = useRef(false)
|
||||
|
||||
const handleTouchStart = (): void => {
|
||||
if (!isMobile) return
|
||||
isLongPressedRef.current = false
|
||||
longPressTimerRef.current = setTimeout(() => {
|
||||
isLongPressedRef.current = true
|
||||
setAnchorEl(document.body)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleTouchEnd = (): void => {
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current)
|
||||
longPressTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (): void => {
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current)
|
||||
longPressTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const [userId, calId] = id.split('/')
|
||||
const isDefault = isPersonal && userId === calId
|
||||
|
||||
const [deletePopupOpen, setDeletePopupOpen] = useState(false)
|
||||
const handleDeleteConfirm = () => {
|
||||
dispatch(removeCalendarAsync({ calId: id, calLink }))
|
||||
const handleDeleteConfirm = async (): Promise<void> => {
|
||||
await dispatch(removeCalendarAsync({ calId: id, calLink }))
|
||||
setDeletePopupOpen(false)
|
||||
handleClose()
|
||||
}
|
||||
@@ -339,6 +364,15 @@ function CalendarSelector({
|
||||
'& .MoreBtn': { opacity: 1 }
|
||||
}
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchMove={handleTouchMove}
|
||||
onClickCapture={e => {
|
||||
if (isLongPressedRef.current) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
@@ -381,26 +415,23 @@ function CalendarSelector({
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
{!isMobile && (
|
||||
<IconButton className="MoreBtn" onClick={handleClick}>
|
||||
<MoreHorizIcon />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
<Menu id={id} anchorEl={anchorEl} open={open} onClose={handleClose}>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setOpen()
|
||||
handleClose()
|
||||
}}
|
||||
>
|
||||
{t('actions.modify')}
|
||||
</MenuItem>
|
||||
{!isDefault && <Divider />}
|
||||
{!isDefault && (
|
||||
<MenuItem onClick={() => setDeletePopupOpen(!deletePopupOpen)}>
|
||||
{isPersonal ? t('actions.delete') : t('actions.remove')}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</ListItem>
|
||||
|
||||
<CalendarSelectorMenu
|
||||
id={id}
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onModify={setOpen}
|
||||
onDelete={() => setDeletePopupOpen(true)}
|
||||
isDefault={isDefault}
|
||||
isPersonal={isPersonal}
|
||||
/>
|
||||
|
||||
<DeleteCalendarDialog
|
||||
deletePopupOpen={deletePopupOpen}
|
||||
@@ -408,8 +439,10 @@ function CalendarSelector({
|
||||
calendars={calendars}
|
||||
id={id}
|
||||
isPersonal={isPersonal}
|
||||
handleDeleteConfirm={handleDeleteConfirm}
|
||||
handleDeleteConfirm={() => void handleDeleteConfirm()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CalendarSelection
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
import { Divider, Menu, MenuItem } from '@linagora/twake-mui'
|
||||
import { useI18n } from 'twake-i18n'
|
||||
|
||||
interface CalendarSelectorDesktopMenuProps {
|
||||
id: string
|
||||
anchorEl: HTMLElement | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onModify: () => void
|
||||
onDelete: () => void
|
||||
isDefault: boolean
|
||||
isPersonal: boolean
|
||||
}
|
||||
|
||||
export const CalendarSelectorDesktopMenu: React.FC<
|
||||
CalendarSelectorDesktopMenuProps
|
||||
> = ({
|
||||
id,
|
||||
anchorEl,
|
||||
open,
|
||||
onClose,
|
||||
onModify,
|
||||
onDelete,
|
||||
isDefault,
|
||||
isPersonal
|
||||
}) => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<Menu id={id} anchorEl={anchorEl} open={open} onClose={onClose}>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
onModify()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{t('actions.modify')}
|
||||
</MenuItem>
|
||||
{!isDefault && <Divider />}
|
||||
{!isDefault && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{isPersonal ? t('actions.delete') : t('actions.remove')}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
styled,
|
||||
SwipeableDrawer,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemText
|
||||
} from '@linagora/twake-mui'
|
||||
import { useI18n } from 'twake-i18n'
|
||||
|
||||
const StyledSwipeableDrawer = styled(SwipeableDrawer)(({ theme }) => ({
|
||||
zIndex: theme.zIndex.modal + 100
|
||||
}))
|
||||
|
||||
interface CalendarSelectorMobileMenuProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onModify: () => void
|
||||
onDelete: () => void
|
||||
isDefault: boolean
|
||||
isPersonal: boolean
|
||||
}
|
||||
|
||||
export const CalendarSelectorMobileMenu: React.FC<
|
||||
CalendarSelectorMobileMenuProps
|
||||
> = ({ open, onClose, onModify, onDelete, isDefault, isPersonal }) => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<StyledSwipeableDrawer
|
||||
anchor="bottom"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onOpen={() => {}}
|
||||
disableAutoFocus
|
||||
>
|
||||
<List>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
onModify()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<ListItemText primary={t('actions.modify')} />
|
||||
</ListItemButton>
|
||||
{!isDefault && (
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={isPersonal ? t('actions.delete') : t('actions.remove')}
|
||||
slotProps={{
|
||||
primary: {
|
||||
color: 'error.main'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
)}
|
||||
</List>
|
||||
</StyledSwipeableDrawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
import { useScreenSizeDetection } from '@/useScreenSizeDetection'
|
||||
import { CalendarSelectorDesktopMenu } from './CalendarSelectorDesktopMenu'
|
||||
import { CalendarSelectorMobileMenu } from './CalendarSelectorMobileMenu'
|
||||
|
||||
interface CalendarSelectorMenuProps {
|
||||
id: string
|
||||
anchorEl: HTMLElement | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onModify: () => void
|
||||
onDelete: () => void
|
||||
isDefault: boolean
|
||||
isPersonal: boolean
|
||||
}
|
||||
|
||||
export const CalendarSelectorMenu: React.FC<CalendarSelectorMenuProps> = ({
|
||||
id,
|
||||
anchorEl,
|
||||
open,
|
||||
onClose,
|
||||
onModify,
|
||||
onDelete,
|
||||
isDefault,
|
||||
isPersonal
|
||||
}) => {
|
||||
const { isTooSmall: isMobile } = useScreenSizeDetection()
|
||||
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<CalendarSelectorDesktopMenu
|
||||
id={id}
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onModify={onModify}
|
||||
onDelete={onDelete}
|
||||
isDefault={isDefault}
|
||||
isPersonal={isPersonal}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CalendarSelectorMobileMenu
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onModify={onModify}
|
||||
onDelete={onDelete}
|
||||
isDefault={isDefault}
|
||||
isPersonal={isPersonal}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,8 @@ export const DesktopSidebar: React.FC<CalendarSidebarProps> = ({
|
||||
paddingLeft: 3,
|
||||
paddingRight: 2,
|
||||
width: '270px',
|
||||
marginTop: isIframe ? 1 : '70px'
|
||||
marginTop: isIframe ? 1 : '70px',
|
||||
height: isIframe ? 'calc(100% - 8px)' : 'calc(100% - 70px)'
|
||||
},
|
||||
zIndex: 5
|
||||
}}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function Loading() {
|
||||
<Box
|
||||
component="img"
|
||||
src={twakeLogo}
|
||||
alt="twake workplace"
|
||||
alt="Workavia"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: '50px',
|
||||
|
||||
@@ -433,8 +433,13 @@ const CalendarSlice = createSlice({
|
||||
state.pending = false
|
||||
if (
|
||||
action.payload?.message.includes('aborted') ||
|
||||
action.error.name === 'AbortError'
|
||||
action.error.name === 'AbortError' ||
|
||||
action.payload?.message?.includes('not found in store')
|
||||
) {
|
||||
// "not found in store" is a boot race condition: useCalendarLoader
|
||||
// dispatches getCalendarDetailAsync for cached selected calendars
|
||||
// before getCalendarsListAsync has fulfilled. Safe to ignore — the
|
||||
// detail fetch will be re-issued once the list is loaded.
|
||||
return
|
||||
}
|
||||
state.error =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, IconButton } from '@linagora/twake-mui'
|
||||
import { Box, IconButton, Tooltip } from '@linagora/twake-mui'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined'
|
||||
@@ -13,23 +13,26 @@ interface EventPreviewHeaderProps {
|
||||
isOwn: boolean
|
||||
isWriteDelegated: boolean
|
||||
isNotPrivate: boolean
|
||||
canEdit: boolean
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
onMoreClick: (e: React.MouseEvent<HTMLElement>) => void
|
||||
onEditInOrganizerCalendar?: () => void
|
||||
editInOrganizerCalendarTooltip?: string
|
||||
}
|
||||
|
||||
export function EventPreviewHeader({
|
||||
event,
|
||||
eventId,
|
||||
isOrganizer,
|
||||
isOwn,
|
||||
isWriteDelegated,
|
||||
isNotPrivate,
|
||||
canEdit,
|
||||
onClose,
|
||||
onEdit,
|
||||
onMoreClick
|
||||
onMoreClick,
|
||||
onEditInOrganizerCalendar,
|
||||
editInOrganizerCalendarTooltip
|
||||
}: EventPreviewHeaderProps) {
|
||||
const canEdit = isOrganizer && (isOwn || (isWriteDelegated && isNotPrivate))
|
||||
const canSeeMore = isNotPrivate || isOwn
|
||||
|
||||
return (
|
||||
@@ -70,6 +73,13 @@ export function EventPreviewHeader({
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
{!canEdit && onEditInOrganizerCalendar && (
|
||||
<Tooltip title={editInOrganizerCalendarTooltip} placement="top">
|
||||
<IconButton size="small" onClick={onEditInOrganizerCalendar}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canSeeMore && (
|
||||
<IconButton size="small" onClick={onMoreClick}>
|
||||
<MoreVertIcon />
|
||||
|
||||
@@ -43,6 +43,8 @@ export default function EventPreviewModal({
|
||||
isWriteDelegated,
|
||||
isOrganizer,
|
||||
isNotPrivate,
|
||||
canEdit,
|
||||
organizerWritableCalendar,
|
||||
openUpdateModal,
|
||||
setOpenUpdateModal,
|
||||
openDuplicateModal,
|
||||
@@ -51,6 +53,7 @@ export default function EventPreviewModal({
|
||||
setHidePreview,
|
||||
toggleActionMenu,
|
||||
setToggleActionMenu,
|
||||
updateModalCalId,
|
||||
openEditModePopup,
|
||||
setOpenEditModePopup,
|
||||
setTypeOfAction,
|
||||
@@ -58,6 +61,7 @@ export default function EventPreviewModal({
|
||||
setAfterChoiceFunc,
|
||||
resolvedTypeOfAction,
|
||||
handleEditClick,
|
||||
handleEditInOrganizerCalendar,
|
||||
handleDeleteClick,
|
||||
handleDuplicateClick
|
||||
} = useEventPreviewState(eventId, calId, tempEvent, open, onClose)
|
||||
@@ -101,9 +105,22 @@ export default function EventPreviewModal({
|
||||
isOwn={isOwn}
|
||||
isWriteDelegated={isWriteDelegated}
|
||||
isNotPrivate={isNotPrivate}
|
||||
canEdit={canEdit}
|
||||
onClose={() => onClose({}, 'backdropClick')}
|
||||
onEdit={handleEditClick}
|
||||
onMoreClick={e => setToggleActionMenu(e.currentTarget)}
|
||||
onEditInOrganizerCalendar={
|
||||
organizerWritableCalendar
|
||||
? handleEditInOrganizerCalendar
|
||||
: undefined
|
||||
}
|
||||
editInOrganizerCalendarTooltip={
|
||||
organizerWritableCalendar
|
||||
? t('eventPreview.editInOrganizerCalendar', {
|
||||
calendarName: organizerWritableCalendar.name
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
@@ -209,7 +226,7 @@ export default function EventPreviewModal({
|
||||
onClose({}, 'backdropClick')
|
||||
}}
|
||||
eventId={eventId}
|
||||
calId={calId}
|
||||
calId={updateModalCalId}
|
||||
typeOfAction={resolvedTypeOfAction}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useAppDispatch, useAppSelector } from '@/app/hooks'
|
||||
import { Calendar } from '@/features/Calendars/CalendarTypes'
|
||||
import { assertThunkSuccess } from '@/utils/assertThunkSuccess'
|
||||
import { getEffectiveEmail } from '@/utils/getEffectiveEmail'
|
||||
import { isEventOrganiser } from '@/utils/isEventOrganiser'
|
||||
@@ -31,6 +32,7 @@ export function useEventPreviewState(
|
||||
const [openDuplicateModal, setOpenDuplicateModal] = useState(false)
|
||||
const [hidePreview, setHidePreview] = useState(false)
|
||||
const [toggleActionMenu, setToggleActionMenu] = useState<Element | null>(null)
|
||||
const [updateModalCalId, setUpdateModalCalId] = useState(calId)
|
||||
|
||||
// Recurring event handling
|
||||
const [openEditModePopup, setOpenEditModePopup] = useState<string | null>(
|
||||
@@ -68,6 +70,22 @@ export function useEventPreviewState(
|
||||
const isNotPrivate =
|
||||
event?.class !== 'PRIVATE' && event?.class !== 'CONFIDENTIAL'
|
||||
|
||||
const canEdit = isOrganizer && (isOwn || (isWriteDelegated && isNotPrivate))
|
||||
|
||||
// If the user cannot edit here but has write access to the organizer's delegated
|
||||
// calendar, surface a shortcut so they don't have to hunt for the source event.
|
||||
const organizerEmail = event?.organizer?.cal_address?.toLowerCase()
|
||||
const organizerWritableCalendar: Calendar | undefined =
|
||||
!canEdit && organizerEmail
|
||||
? Object.values(calendars.list).find(
|
||||
cal =>
|
||||
cal.delegated &&
|
||||
cal.access?.write &&
|
||||
cal.owner?.emails?.some(e => e.toLowerCase() === organizerEmail) &&
|
||||
cal.events[eventId] !== undefined
|
||||
)
|
||||
: undefined
|
||||
|
||||
const contextualizedEvent =
|
||||
event && calendar && user ? createEventContext(event, calendar, user) : null
|
||||
|
||||
@@ -97,6 +115,22 @@ export function useEventPreviewState(
|
||||
|
||||
// Action handlers
|
||||
const handleEditClick = () => {
|
||||
setUpdateModalCalId(calId)
|
||||
if (isRecurring) {
|
||||
setAfterChoiceFunc(() => () => {
|
||||
setHidePreview(true)
|
||||
setOpenUpdateModal(true)
|
||||
})
|
||||
setOpenEditModePopup('edit')
|
||||
} else {
|
||||
setHidePreview(true)
|
||||
setOpenUpdateModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditInOrganizerCalendar = () => {
|
||||
if (!organizerWritableCalendar) return
|
||||
setUpdateModalCalId(organizerWritableCalendar.id)
|
||||
if (isRecurring) {
|
||||
setAfterChoiceFunc(() => () => {
|
||||
setHidePreview(true)
|
||||
@@ -158,6 +192,8 @@ export function useEventPreviewState(
|
||||
isWriteDelegated,
|
||||
isOrganizer,
|
||||
isNotPrivate,
|
||||
canEdit,
|
||||
organizerWritableCalendar,
|
||||
|
||||
// Modal state
|
||||
openUpdateModal,
|
||||
@@ -168,6 +204,7 @@ export function useEventPreviewState(
|
||||
setHidePreview,
|
||||
toggleActionMenu,
|
||||
setToggleActionMenu,
|
||||
updateModalCalId,
|
||||
|
||||
// Recurring state
|
||||
openEditModePopup,
|
||||
@@ -180,6 +217,7 @@ export function useEventPreviewState(
|
||||
|
||||
// Handlers
|
||||
handleEditClick,
|
||||
handleEditInOrganizerCalendar,
|
||||
handleDeleteClick,
|
||||
handleDuplicateClick,
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export function makeVevent(
|
||||
'description',
|
||||
{},
|
||||
'text',
|
||||
'This is an automatic alarm sent by Twake Calendar'
|
||||
'This is an automatic alarm sent by Workavia Calendar'
|
||||
]
|
||||
]
|
||||
vevent.push([['valarm', valarm]])
|
||||
|
||||
@@ -1,79 +1,23 @@
|
||||
import { useAppDispatch, useAppSelector } from '@/app/hooks'
|
||||
import { AppDispatch } from '@/app/store'
|
||||
import { defaultColors } from '@/utils/defaultColors'
|
||||
import { browserDefaultTimeZone } from '@/utils/timezone'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Stack,
|
||||
Typography
|
||||
} from '@linagora/twake-mui'
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||
import RepeatIcon from '@mui/icons-material/Repeat'
|
||||
import SquareRoundedIcon from '@mui/icons-material/SquareRounded'
|
||||
import VideocamIcon from '@mui/icons-material/Videocam'
|
||||
import { useState } from 'react'
|
||||
import { useI18n } from 'twake-i18n'
|
||||
import logo from '../../static/noResult-logo.svg'
|
||||
import { getEventAsync } from '../Calendars/services'
|
||||
import EventPreviewModal from '@/features/Events/EventPreview'
|
||||
import { CalendarEvent } from '../Events/EventsTypes'
|
||||
import { defaultColors } from '@/utils/defaultColors'
|
||||
import { Box, IconButton, Typography } from '@linagora/twake-mui'
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||
import SquareRoundedIcon from '@mui/icons-material/SquareRounded'
|
||||
import { useI18n } from 'twake-i18n'
|
||||
import { setView } from '../Settings/SettingsSlice'
|
||||
import { ResultsList } from './ResultsList'
|
||||
import './searchResult.styl'
|
||||
import {
|
||||
RenderDate,
|
||||
RenderText,
|
||||
RenderOrganizer,
|
||||
RenderTime,
|
||||
RenderTitle,
|
||||
RenderVideoJoin
|
||||
} from './searchResultsComponents'
|
||||
import { SearchEventResult } from './types/SearchEventResult'
|
||||
|
||||
const styles = {
|
||||
M3BodyLarge: {
|
||||
fontFamily: 'Roboto',
|
||||
fontWeight: 400,
|
||||
fontStyle: 'normal',
|
||||
fontSize: '22px',
|
||||
lineHeight: '28px',
|
||||
letterSpacing: '0%',
|
||||
color: '#243B55'
|
||||
},
|
||||
M3BodyMedium1: {
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: 400,
|
||||
fontStyle: 'normal',
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
letterSpacing: '-0.15px',
|
||||
color: '#243B55'
|
||||
},
|
||||
M3BodyMedium: {
|
||||
fontFamily: 'Roboto',
|
||||
fontWeight: 400,
|
||||
fontStyle: 'normal',
|
||||
fontSize: '14px',
|
||||
lineHeight: '20px',
|
||||
letterSpacing: '0.25px',
|
||||
verticalAlign: 'middle',
|
||||
color: '#8C9CAF'
|
||||
},
|
||||
M3BodyMedium3: {
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: 400,
|
||||
fontSize: '14px',
|
||||
lineHeight: '20px',
|
||||
letterSpacing: '0.25px',
|
||||
verticalAlign: 'middle',
|
||||
color: '#8C9CAF'
|
||||
},
|
||||
M3TitleMedium: {
|
||||
fontFamily: 'Roboto',
|
||||
fontWeight: 500,
|
||||
fontStyle: 'medium',
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
letterSpacing: '0.15px',
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
color: '#243B55'
|
||||
}
|
||||
}
|
||||
import { useEventPreview } from './useEventPreview'
|
||||
|
||||
export default function DesktopSearchResultsPage(): JSX.Element {
|
||||
const { t } = useI18n()
|
||||
@@ -82,48 +26,6 @@ export default function DesktopSearchResultsPage(): JSX.Element {
|
||||
state => state.searchResult
|
||||
)
|
||||
|
||||
let layout
|
||||
|
||||
if (loading) {
|
||||
layout = (
|
||||
<Box className="loading">
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
)
|
||||
} else if (error) {
|
||||
layout = (
|
||||
<Box className="error">
|
||||
<Typography className="error-text">{error}</Typography>
|
||||
</Box>
|
||||
)
|
||||
} else if (!hits) {
|
||||
layout = (
|
||||
<Box className="noResults">
|
||||
<img className="logo" src={logo} alt={t('search.noResults')} />
|
||||
<Typography sx={styles.M3TitleMedium}>
|
||||
{t('search.noResults')}
|
||||
</Typography>
|
||||
<Typography sx={styles.M3BodyMedium}>
|
||||
{t('search.noResultsSubtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
layout = (
|
||||
<Box className="search-result-content-body">
|
||||
<Stack sx={{ mt: 2 }}>
|
||||
{results.map((r: SearchEventResult, idx: number) => (
|
||||
<ResultItem
|
||||
key={`row-${idx}-event-${r.data.uid}`}
|
||||
eventData={r}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="search-layout">
|
||||
<Box className="search-result-content-header">
|
||||
@@ -137,23 +39,39 @@ export default function DesktopSearchResultsPage(): JSX.Element {
|
||||
<Typography variant="h5">{t('search.resultsTitle')}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{layout}
|
||||
|
||||
<ResultsList
|
||||
loading={loading}
|
||||
error={error}
|
||||
hits={hits}
|
||||
results={results}
|
||||
renderItem={(result, idx) => (
|
||||
<DesktopResultItem
|
||||
key={`row-${idx}-event-${result.data.uid}`}
|
||||
eventData={result}
|
||||
/>
|
||||
)}
|
||||
noResultsTitleSx={{
|
||||
fontWeight: 500,
|
||||
textAlign: 'center',
|
||||
color: 'text.primary'
|
||||
}}
|
||||
noResultsSubtitleSx={{ color: 'text.secondary' }}
|
||||
stackSx={{ mt: 2 }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultItem({
|
||||
eventData,
|
||||
dispatch
|
||||
function DesktopResultItem({
|
||||
eventData
|
||||
}: {
|
||||
eventData: SearchEventResult
|
||||
dispatch: AppDispatch
|
||||
}): JSX.Element {
|
||||
const { t } = useI18n()
|
||||
const startDate = new Date(eventData.data.start)
|
||||
const endDate = eventData.data.end ? new Date(eventData.data.end) : startDate
|
||||
const timeZone =
|
||||
useAppSelector(state => state.settings.timeZone) ?? browserDefaultTimeZone
|
||||
|
||||
const calendar = useAppSelector(
|
||||
state =>
|
||||
state.calendars.list[
|
||||
@@ -162,32 +80,10 @@ function ResultItem({
|
||||
)
|
||||
const calendarColor = calendar?.color?.light
|
||||
|
||||
const [openPreview, setOpenPreview] = useState(false)
|
||||
|
||||
const handleOpenResult = async (
|
||||
eventData: SearchEventResult
|
||||
): Promise<void> => {
|
||||
if (calendar) {
|
||||
const event = {
|
||||
URL: eventData._links.self.href,
|
||||
calId: calendar.id,
|
||||
uid: eventData.data.uid,
|
||||
start: eventData.data.start,
|
||||
end: eventData.data.end,
|
||||
allday: eventData.data.allDay,
|
||||
attendee: eventData.data.attendees,
|
||||
class: eventData.data.class,
|
||||
description: eventData.data.description,
|
||||
stamp: eventData.data.dtstamp,
|
||||
location: eventData.data.location,
|
||||
organizer: eventData.data.organizer,
|
||||
title: eventData.data.summary,
|
||||
timezone: timeZone
|
||||
} as CalendarEvent
|
||||
await dispatch(getEventAsync(event))
|
||||
setOpenPreview(true)
|
||||
}
|
||||
}
|
||||
const { openPreview, setOpenPreview, handleOpen, timeZone } = useEventPreview(
|
||||
eventData,
|
||||
calendar
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -197,48 +93,29 @@ function ResultItem({
|
||||
flexDirection: 'row',
|
||||
gap: 2,
|
||||
p: 3,
|
||||
borderTop: '1px solid #F3F6F9',
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { backgroundColor: '#e7e7e7ff' },
|
||||
'&:hover': { backgroundColor: 'action.hover' },
|
||||
alignItems: 'center',
|
||||
textAlign: 'left',
|
||||
maxWidth: '80vw'
|
||||
}}
|
||||
onClick={() => void handleOpenResult(eventData)}
|
||||
onClick={() => void handleOpen()}
|
||||
>
|
||||
<Typography sx={{ ...styles.M3BodyLarge, minWidth: '90px' }}>
|
||||
{startDate.toLocaleDateString(t('locale'), {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone
|
||||
})}
|
||||
{startDate.toDateString() !== endDate.toDateString() && (
|
||||
<>
|
||||
{' - '}
|
||||
{endDate.toLocaleDateString(t('locale'), {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
{!eventData.data.allDay && (
|
||||
<Typography sx={{ ...styles.M3BodyMedium1, minWidth: '120px' }}>
|
||||
{startDate.toLocaleTimeString(t('locale'), {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone
|
||||
})}
|
||||
-
|
||||
{endDate.toLocaleTimeString(t('locale'), {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<RenderDate
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
t={t}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
<RenderTime
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
allDay={!!eventData.data.allDay}
|
||||
t={t}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
<SquareRoundedIcon
|
||||
style={{
|
||||
color: calendarColor ?? defaultColors[0].light,
|
||||
@@ -247,72 +124,22 @@ function ResultItem({
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<Box display="flex" flexDirection="row" gap={1} sx={{ minWidth: 0 }}>
|
||||
<Typography sx={styles.M3BodyLarge}>
|
||||
{eventData.data.summary || t('event.untitled')}
|
||||
</Typography>
|
||||
{eventData.data.isRecurrentMaster && <RepeatIcon />}
|
||||
|
||||
<RenderTitle
|
||||
summary={eventData.data.summary}
|
||||
isRecurrent={!!eventData.data.isRecurrentMaster}
|
||||
t={t}
|
||||
/>
|
||||
<RenderOrganizer organizer={eventData.data.organizer} />
|
||||
<RenderText text={eventData.data.location} />
|
||||
<RenderText text={eventData.data.description} />
|
||||
<RenderVideoJoin
|
||||
t={t}
|
||||
url={eventData.data['x-openpaas-videoconference']}
|
||||
/>
|
||||
</Box>
|
||||
{(eventData.data.organizer?.cn || eventData.data.organizer?.email) && (
|
||||
<Typography
|
||||
sx={{
|
||||
...styles.M3BodyMedium1,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: '150px',
|
||||
maxWidth: '150px'
|
||||
}}
|
||||
>
|
||||
{eventData.data.organizer.cn || eventData.data.organizer.email}
|
||||
</Typography>
|
||||
)}
|
||||
{eventData.data?.location && (
|
||||
<Typography
|
||||
sx={{
|
||||
...styles.M3BodyMedium,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: '150px',
|
||||
maxWidth: '250px'
|
||||
}}
|
||||
>
|
||||
{eventData.data.location}
|
||||
</Typography>
|
||||
)}
|
||||
{eventData.data?.description && (
|
||||
<Typography
|
||||
sx={{
|
||||
...styles.M3BodyMedium3,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
{eventData.data.description.replace(/\n/g, ' ')}
|
||||
</Typography>
|
||||
)}
|
||||
{eventData.data['x-openpaas-videoconference'] && (
|
||||
<Button
|
||||
startIcon={<VideocamIcon />}
|
||||
sx={{ flexShrink: 0, ml: 'auto' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
window.open(
|
||||
eventData.data['x-openpaas-videoconference'],
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}}
|
||||
>
|
||||
{t('eventPreview.joinVideoShort')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{calendar && calendar.events[eventData.data.uid] && (
|
||||
|
||||
{calendar?.events?.[eventData.data.uid] && (
|
||||
<EventPreviewModal
|
||||
eventId={eventData.data.uid}
|
||||
calId={calendar.id}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { useAppSelector } from '@/app/hooks'
|
||||
import EventPreviewModal from '@/features/Events/EventPreview'
|
||||
import { Box } from '@linagora/twake-mui'
|
||||
import { useI18n } from 'twake-i18n'
|
||||
import { AttendeesFilter } from './AttendeesFilter'
|
||||
import DesktopSearchResultsPage from './DesktopSearchResultsPage'
|
||||
import { normalizeCalendars } from './calendarColorUtils'
|
||||
import { OrganizersFilter } from './OrganizersFilter'
|
||||
import { ResultsList } from './ResultsList'
|
||||
import { SearchInFilter } from './SearchInFilter'
|
||||
import './searchResult.styl'
|
||||
import {
|
||||
RenderMobileDate,
|
||||
RenderMobileEventCard
|
||||
} from './searchResultsComponents'
|
||||
import { SearchEventResult } from './types/SearchEventResult'
|
||||
import { useEventPreview } from './useEventPreview'
|
||||
|
||||
const MobileSearchResultsPage: React.FC = () => {
|
||||
const searchResults = useAppSelector(state => state.searchResult)
|
||||
@@ -15,21 +24,46 @@ const MobileSearchResultsPage: React.FC = () => {
|
||||
searchResults.searchParams.filters.attendees.length > 0
|
||||
|
||||
const displaySearch =
|
||||
(!!searchResults.hits || !!searchResults.error || searchResults.loading) &&
|
||||
!!searchResults.hits ||
|
||||
!!searchResults.error ||
|
||||
searchResults.loading ||
|
||||
hasSearchParams
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<FiltersButtons />
|
||||
{displaySearch && <DesktopSearchResultsPage />}
|
||||
</>
|
||||
{displaySearch && (
|
||||
<Box sx={{ m: 2, flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
<ResultsList
|
||||
loading={searchResults.loading}
|
||||
error={searchResults.error}
|
||||
hits={searchResults.hits}
|
||||
results={searchResults.results}
|
||||
renderItem={(result, idx) => (
|
||||
<MobileResultItem
|
||||
key={`row-${idx}-event-${result.data.uid}`}
|
||||
eventData={result}
|
||||
/>
|
||||
)}
|
||||
noResultsTitleSx={{ variant: 'h5' }}
|
||||
noResultsSubtitleSx={{ variant: 'subtitle1' }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileSearchResultsPage
|
||||
|
||||
const FiltersButtons: React.FC = () => {
|
||||
return (
|
||||
const FiltersButtons: React.FC = () => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -47,5 +81,57 @@ const FiltersButtons: React.FC = () => {
|
||||
<OrganizersFilter mode="mobile" />
|
||||
<AttendeesFilter mode="mobile" />
|
||||
</Box>
|
||||
)
|
||||
|
||||
const MobileResultItem: React.FC<{ eventData: SearchEventResult }> = ({
|
||||
eventData
|
||||
}) => {
|
||||
const { t } = useI18n()
|
||||
|
||||
const rawCalendars = useAppSelector(state => state.calendars.list)
|
||||
const calendars = normalizeCalendars(rawCalendars)
|
||||
|
||||
const calId = `${eventData.data.userId}/${eventData.data.calendarId}`
|
||||
const calendar = calendars[calId]
|
||||
|
||||
const { openPreview, setOpenPreview, handleOpen, timeZone } = useEventPreview(
|
||||
eventData,
|
||||
calendar
|
||||
)
|
||||
|
||||
const startDate = new Date(eventData.data.start)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '10%',
|
||||
gridAutoFlow: 'column',
|
||||
gap: 2,
|
||||
pt: 1,
|
||||
cursor: 'pointer',
|
||||
alignItems: 'center',
|
||||
textAlign: 'left'
|
||||
}}
|
||||
onClick={() => void handleOpen()}
|
||||
>
|
||||
<RenderMobileDate startDate={startDate} t={t} timeZone={timeZone} />
|
||||
<RenderMobileEventCard
|
||||
eventData={eventData}
|
||||
calendar={calendar}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{calendar?.events?.[eventData.data.uid] && (
|
||||
<EventPreviewModal
|
||||
eventId={eventData.data.uid}
|
||||
calId={calendar.id}
|
||||
open={openPreview}
|
||||
onClose={() => setOpenPreview(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Box, CircularProgress, Stack, Typography } from '@linagora/twake-mui'
|
||||
import { useI18n } from 'twake-i18n'
|
||||
import logo from '../../static/noResult-logo.svg'
|
||||
import { SearchEventResult } from './types/SearchEventResult'
|
||||
import { SxProps } from '@mui/material'
|
||||
|
||||
interface ResultsListProps {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
hits: number | null
|
||||
results: SearchEventResult[]
|
||||
renderItem: (result: SearchEventResult, idx: number) => React.ReactNode
|
||||
noResultsTitleSx?: SxProps
|
||||
noResultsSubtitleSx?: SxProps
|
||||
stackSx?: SxProps
|
||||
}
|
||||
|
||||
export const ResultsList: React.FC<ResultsListProps> = ({
|
||||
loading,
|
||||
error,
|
||||
hits,
|
||||
results,
|
||||
renderItem,
|
||||
noResultsTitleSx,
|
||||
noResultsSubtitleSx,
|
||||
stackSx
|
||||
}) => {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box className="loading">
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box className="error">
|
||||
<Typography className="error-text">{error}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hits) {
|
||||
return (
|
||||
<Box className="noResults">
|
||||
<img className="logo" src={logo} alt={t('search.noResults')} />
|
||||
<Typography
|
||||
sx={noResultsTitleSx}
|
||||
variant={noResultsTitleSx ? undefined : 'h5'}
|
||||
>
|
||||
{t('search.noResults')}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={noResultsSubtitleSx}
|
||||
variant={noResultsSubtitleSx ? undefined : 'subtitle1'}
|
||||
>
|
||||
{t('search.noResultsSubtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="search-result-content-body">
|
||||
<Stack sx={stackSx}>
|
||||
{results.map((result, idx) => renderItem(result, idx))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Calendar } from '@/features/Calendars/CalendarTypes'
|
||||
|
||||
export type NormalizedColor = { light: string; dark: string }
|
||||
export type NormalizedCalendar = Omit<Calendar, 'color'> & {
|
||||
color: NormalizedColor
|
||||
}
|
||||
|
||||
/**
|
||||
* EventChip expects calendar.color to be { light: string; dark: string }.
|
||||
* The Redux store may hold color as a plain string — normalize it here.
|
||||
*/
|
||||
export function normalizeCalendarColor(
|
||||
color: Calendar['color']
|
||||
): NormalizedColor {
|
||||
if (
|
||||
color &&
|
||||
typeof color === 'object' &&
|
||||
'light' in color &&
|
||||
'dark' in color
|
||||
) {
|
||||
return color as NormalizedColor
|
||||
}
|
||||
const hex = typeof color === 'string' ? color : '#ffffff'
|
||||
return { light: hex, dark: hex }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of the calendars map with every color normalized to { light, dark }.
|
||||
*/
|
||||
export function normalizeCalendars(
|
||||
calendars: Record<string, Calendar>
|
||||
): Record<string, NormalizedCalendar> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(calendars).map(([key, cal]) => [
|
||||
key,
|
||||
{ ...cal, color: normalizeCalendarColor(cal.color) }
|
||||
])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
getBestColor,
|
||||
getTitleStyle
|
||||
} from '@/components/Event/EventChip/EventChipUtils'
|
||||
import { Calendar } from '@/features/Calendars/CalendarTypes'
|
||||
import { defaultColors } from '@/utils/defaultColors'
|
||||
import { Box, Button, Card, CardHeader, Typography } from '@linagora/twake-mui'
|
||||
import RepeatIcon from '@mui/icons-material/Repeat'
|
||||
import VideocamIcon from '@mui/icons-material/Videocam'
|
||||
import React from 'react'
|
||||
import { useI18n } from 'twake-i18n'
|
||||
import { SearchEventResult } from './types/SearchEventResult'
|
||||
|
||||
interface DateProps {
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
t: (key: string) => string
|
||||
timeZone: string
|
||||
}
|
||||
|
||||
interface TimeProps {
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
allDay: boolean
|
||||
t: (key: string) => string
|
||||
timeZone: string
|
||||
}
|
||||
|
||||
interface TitleProps {
|
||||
summary?: string
|
||||
isRecurrent: boolean
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
interface OrganizerProps {
|
||||
organizer?: {
|
||||
cn?: string
|
||||
email?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface VideoJoinProps {
|
||||
url?: string
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
interface MobileDateProps {
|
||||
startDate: Date
|
||||
t: (key: string) => string
|
||||
timeZone: string
|
||||
}
|
||||
|
||||
interface MobileEventCardProps {
|
||||
eventData: SearchEventResult
|
||||
calendar: Calendar | undefined
|
||||
timeZone: string
|
||||
}
|
||||
|
||||
export const RenderDate: React.FC<DateProps> = ({
|
||||
startDate,
|
||||
endDate,
|
||||
t,
|
||||
timeZone
|
||||
}) => {
|
||||
const dayKey = (d: Date): string =>
|
||||
d.toLocaleDateString('en-CA', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone
|
||||
})
|
||||
return (
|
||||
<Typography variant="h4" sx={{ fontWeight: 400, minWidth: '90px' }}>
|
||||
{startDate.toLocaleDateString(t('locale'), {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone
|
||||
})}
|
||||
{dayKey(startDate) !== dayKey(endDate) && (
|
||||
<>
|
||||
{' - '}
|
||||
{endDate.toLocaleDateString(t('locale'), {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
export const RenderTime: React.FC<TimeProps> = ({
|
||||
startDate,
|
||||
endDate,
|
||||
allDay,
|
||||
t,
|
||||
timeZone
|
||||
}) => {
|
||||
if (allDay) return null
|
||||
return (
|
||||
<Typography variant="body1" sx={{ minWidth: '120px' }}>
|
||||
{startDate.toLocaleTimeString(t('locale'), {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone
|
||||
})}
|
||||
{' - '}
|
||||
{endDate.toLocaleTimeString(t('locale'), {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone
|
||||
})}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
export const RenderTitle: React.FC<TitleProps> = ({
|
||||
summary,
|
||||
isRecurrent,
|
||||
t
|
||||
}) => (
|
||||
<Box display="flex" flexDirection="row" gap={1} sx={{ minWidth: 0 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 400 }}>
|
||||
{summary || t('event.untitled')}
|
||||
</Typography>
|
||||
{isRecurrent && <RepeatIcon />}
|
||||
</Box>
|
||||
)
|
||||
|
||||
export const RenderOrganizer: React.FC<OrganizerProps> = ({ organizer }) => {
|
||||
if (!organizer?.cn && !organizer?.email) return null
|
||||
return (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: '150px',
|
||||
maxWidth: '150px'
|
||||
}}
|
||||
>
|
||||
{organizer.cn || organizer.email}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
export const RenderText: React.FC<{ text?: string }> = ({ text }) => {
|
||||
if (!text) return null
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: '150px',
|
||||
maxWidth: '250px'
|
||||
}}
|
||||
>
|
||||
{text.replace(/\n/g, ' ')}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
export const RenderVideoJoin: React.FC<VideoJoinProps> = ({ url, t }) => {
|
||||
if (!url) return null
|
||||
return (
|
||||
<Button
|
||||
startIcon={<VideocamIcon />}
|
||||
sx={{ flexShrink: 0, ml: 'auto' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
||||
return
|
||||
window.open(parsed.toString(), '_blank', 'noopener,noreferrer')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('eventPreview.joinVideoShort')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export const RenderMobileDate: React.FC<MobileDateProps> = ({
|
||||
startDate,
|
||||
t,
|
||||
timeZone
|
||||
}) => (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 400 }}>
|
||||
{startDate.toLocaleDateString(t('locale'), { day: '2-digit', timeZone })}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{startDate
|
||||
.toLocaleDateString(t('locale'), { weekday: 'short', timeZone })
|
||||
.toUpperCase()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
|
||||
export const RenderMobileEventCard: React.FC<MobileEventCardProps> = ({
|
||||
eventData,
|
||||
calendar,
|
||||
timeZone
|
||||
}) => {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (!calendar) return null
|
||||
|
||||
const startDate = new Date(eventData.data.start)
|
||||
const bestColor = calendar.color
|
||||
? getBestColor(calendar.color as { light: string; dark: string })
|
||||
: defaultColors[0].dark
|
||||
const titleStyle = getTitleStyle(bestColor, 'ACCEPTED', calendar, false)
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
height: 'stretch',
|
||||
width: '100%',
|
||||
borderRadius: '8px',
|
||||
p: 1,
|
||||
boxShadow: 'none',
|
||||
backgroundColor: calendar?.color?.light,
|
||||
color: calendar?.color?.dark,
|
||||
border: '1px solid',
|
||||
borderColor: 'background.paper',
|
||||
display: 'flex'
|
||||
}}
|
||||
data-testid={`event-card-${eventData.data.uid}`}
|
||||
>
|
||||
<CardHeader
|
||||
sx={{ p: '0px', '& .MuiCardHeader-content': { overflow: 'hidden' } }}
|
||||
title={
|
||||
<Typography variant="body2" noWrap style={titleStyle}>
|
||||
{eventData.data.summary || t('event.untitled')}
|
||||
</Typography>
|
||||
}
|
||||
subheader={
|
||||
!eventData.data.allDay && (
|
||||
<Typography
|
||||
style={{
|
||||
color: titleStyle.color,
|
||||
opacity: '70%',
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: '500',
|
||||
fontSize: '10px',
|
||||
lineHeight: '16px',
|
||||
letterSpacing: '0%',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
>
|
||||
{startDate.toLocaleTimeString(t('locale'), {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: timeZone
|
||||
})}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useAppDispatch, useAppSelector } from '@/app/hooks'
|
||||
import { Calendar } from '@/features/Calendars/CalendarTypes'
|
||||
import { browserDefaultTimeZone } from '@/utils/timezone'
|
||||
import { useState } from 'react'
|
||||
import { getEventAsync } from '../Calendars/services'
|
||||
import { CalendarEvent } from '../Events/EventsTypes'
|
||||
import { SearchEventResult } from './types/SearchEventResult'
|
||||
|
||||
export function useEventPreview(
|
||||
eventData: SearchEventResult,
|
||||
calendar: Calendar | undefined
|
||||
): {
|
||||
openPreview: boolean
|
||||
setOpenPreview: (b: boolean) => void
|
||||
handleOpen: () => Promise<void>
|
||||
timeZone: string
|
||||
} {
|
||||
const dispatch = useAppDispatch()
|
||||
const timeZone =
|
||||
useAppSelector(state => state.settings.timeZone) ?? browserDefaultTimeZone
|
||||
const [openPreview, setOpenPreview] = useState(false)
|
||||
|
||||
const handleOpen = async (): Promise<void> => {
|
||||
if (!calendar) return
|
||||
const event: CalendarEvent = {
|
||||
URL: eventData._links.self.href,
|
||||
calId: calendar.id,
|
||||
uid: eventData.data.uid,
|
||||
start: eventData.data.start,
|
||||
end: eventData.data.end,
|
||||
allday: eventData.data.allDay,
|
||||
attendee: eventData.data.attendees,
|
||||
class: eventData.data.class,
|
||||
description: eventData.data.description,
|
||||
stamp: eventData.data.dtstamp,
|
||||
location: eventData.data.location,
|
||||
organizer: eventData.data.organizer,
|
||||
title: eventData.data.summary,
|
||||
timezone: timeZone
|
||||
}
|
||||
await dispatch(getEventAsync(event))
|
||||
setOpenPreview(true)
|
||||
}
|
||||
|
||||
return { openPreview, setOpenPreview, handleOpen, timeZone }
|
||||
}
|
||||
@@ -4,11 +4,15 @@ import { useScreenSizeDetection } from '@/useScreenSizeDetection'
|
||||
import { SettingErrorSnackbars } from './SettingErrorSnackbars'
|
||||
import { DesktopSettingsPage } from './DesktopSettingsPage'
|
||||
import { MobileSettingsPage } from './MobileSettingsPage'
|
||||
import { Menubar, type MenubarProps } from '@/components/Menubar/Menubar'
|
||||
|
||||
export type SidebarNavItem = 'settings' | 'sync'
|
||||
export type SettingsSubTab = 'settings' | 'notifications'
|
||||
|
||||
const SettingsPage: React.FC<{ isInIframe?: boolean }> = ({ isInIframe }) => {
|
||||
const SettingsPage: React.FC<{
|
||||
menubarProps?: MenubarProps
|
||||
isInIframe?: boolean
|
||||
}> = ({ menubarProps, isInIframe }) => {
|
||||
const { isTooSmall: isMobile } = useScreenSizeDetection()
|
||||
|
||||
const [activeSettingsSubTab, setActiveSettingsSubTab] =
|
||||
@@ -46,6 +50,9 @@ const SettingsPage: React.FC<{ isInIframe?: boolean }> = ({ isInIframe }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isInIframe && isMobile && menubarProps && <Menubar {...menubarProps} />}
|
||||
|
||||
<main
|
||||
className={`main-layout settings-layout${isInIframe ? ' isInIframe' : ''} ${isMobile ? 'settings-layout--mobile' : ''}`}
|
||||
>
|
||||
@@ -84,11 +91,16 @@ const SettingsPage: React.FC<{ isInIframe?: boolean }> = ({ isInIframe }) => {
|
||||
handleLanguageErrorClose={handleLanguageErrorClose}
|
||||
handleTimeZoneErrorClose={handleTimeZoneErrorClose}
|
||||
handleAlarmEmailsErrorClose={handleAlarmEmailsErrorClose}
|
||||
handleHideDeclinedEventsErrorClose={handleHideDeclinedEventsErrorClose}
|
||||
handleDisplayWeekNumbersErrorClose={handleDisplayWeekNumbersErrorClose}
|
||||
handleHideDeclinedEventsErrorClose={
|
||||
handleHideDeclinedEventsErrorClose
|
||||
}
|
||||
handleDisplayWeekNumbersErrorClose={
|
||||
handleDisplayWeekNumbersErrorClose
|
||||
}
|
||||
setWorkingDaysErrorOpen={setWorkingDaysErrorOpen}
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -268,7 +268,7 @@
|
||||
"notifications": "Notifications",
|
||||
"sync": "Sync",
|
||||
"language": "Language",
|
||||
"languageDescription": "This will be the language used in your Twake Calendar",
|
||||
"languageDescription": "This will be the language used in your Workavia Calendar",
|
||||
"languageSelector": "Language selector",
|
||||
"languageUpdateError": "Failed to update language",
|
||||
"timeZone": "Timezone",
|
||||
@@ -319,6 +319,7 @@
|
||||
},
|
||||
"showMore": "Show more",
|
||||
"showLess": "Show less",
|
||||
"editInOrganizerCalendar": "Edit in %{calendarName}",
|
||||
"joinVideo": "Join the video conference",
|
||||
"joinVideoShort": "Join",
|
||||
"guests": "%{count} guests",
|
||||
@@ -401,7 +402,7 @@
|
||||
},
|
||||
"mobile": {
|
||||
"comingSoon": {
|
||||
"subtitle": "Please open Twake Calendar on a desktop to enjoy all features. A fully optimized mobile version is coming soon.",
|
||||
"subtitle": "Please open Workavia Calendar on a desktop to enjoy all features. A fully optimized mobile version is coming soon.",
|
||||
"title": "Mobile version coming soon"
|
||||
}
|
||||
},
|
||||
|
||||
+3
-2
@@ -269,7 +269,7 @@
|
||||
"notifications": "Notifications",
|
||||
"sync": "Synchronisation",
|
||||
"language": "Langue",
|
||||
"languageDescription": "Ce sera la langue utilisée dans votre Twake Calendar",
|
||||
"languageDescription": "Ce sera la langue utilisée dans votre Workavia Calendar",
|
||||
"languageSelector": "Sélecteur de langue",
|
||||
"languageUpdateError": "Échec de la mise à jour de la langue",
|
||||
"timeZone": "Fuseau horaire",
|
||||
@@ -320,6 +320,7 @@
|
||||
},
|
||||
"showMore": "Afficher plus",
|
||||
"showLess": "Afficher moins",
|
||||
"editInOrganizerCalendar": "Modifier dans %{calendarName}",
|
||||
"joinVideo": "Rejoindre la visioconférence",
|
||||
"joinVideoShort": "Rejoindre",
|
||||
"guests": "%{count} participants",
|
||||
@@ -402,7 +403,7 @@
|
||||
},
|
||||
"mobile": {
|
||||
"comingSoon": {
|
||||
"subtitle": "Veuillez ouvrir Twake Calendar sur un ordinateur pour profiter de toutes les fonctionnalités. Une version mobile optimisée arrive bientôt.",
|
||||
"subtitle": "Veuillez ouvrir Workavia Calendar sur un ordinateur pour profiter de toutes les fonctionnalités. Une version mobile optimisée arrive bientôt.",
|
||||
"title": "Version mobile bientôt disponible"
|
||||
}
|
||||
},
|
||||
|
||||
+3
-2
@@ -269,7 +269,7 @@
|
||||
"notifications": "Уведомления",
|
||||
"sync": "Синхронизация",
|
||||
"language": "Язык",
|
||||
"languageDescription": "Это будет язык, используемый в вашем Twake Calendar",
|
||||
"languageDescription": "Это будет язык, используемый в вашем Workavia Calendar",
|
||||
"languageSelector": "Выбор языка",
|
||||
"languageUpdateError": "Не удалось обновить язык",
|
||||
"timeZone": "Часовой пояс",
|
||||
@@ -320,6 +320,7 @@
|
||||
},
|
||||
"showMore": "Показать больше",
|
||||
"showLess": "Показать меньше",
|
||||
"editInOrganizerCalendar": "Редактировать в %{calendarName}",
|
||||
"joinVideo": "Присоединиться к видеоконференции",
|
||||
"joinVideoShort": "Присоединиться",
|
||||
"guests": "%{count} гостей",
|
||||
@@ -402,7 +403,7 @@
|
||||
},
|
||||
"mobile": {
|
||||
"comingSoon": {
|
||||
"subtitle": "Пожалуйста, откройте Twake Calendar на компьютере, чтобы воспользоваться всеми функциями. Полностью оптимизированная мобильная версия скоро появится.",
|
||||
"subtitle": "Пожалуйста, откройте Workavia Calendar на компьютере, чтобы воспользоваться всеми функциями. Полностью оптимизированная мобильная версия скоро появится.",
|
||||
"title": "Мобильная версия скоро будет доступна"
|
||||
}
|
||||
},
|
||||
|
||||
+3
-2
@@ -267,7 +267,7 @@
|
||||
"notifications": "Thông báo",
|
||||
"sync": "Đồng bộ",
|
||||
"language": "Ngôn ngữ",
|
||||
"languageDescription": "Đây sẽ là ngôn ngữ được sử dụng trong Twake Calendar của bạn",
|
||||
"languageDescription": "Đây sẽ là ngôn ngữ được sử dụng trong Workavia Calendar của bạn",
|
||||
"languageSelector": "Chọn ngôn ngữ",
|
||||
"languageUpdateError": "Không cập nhật được ngôn ngữ",
|
||||
"timeZone": "Múi giờ",
|
||||
@@ -318,6 +318,7 @@
|
||||
},
|
||||
"showMore": "Xem thêm",
|
||||
"showLess": "Thu gọn",
|
||||
"editInOrganizerCalendar": "Chỉnh sửa trong %{calendarName}",
|
||||
"joinVideo": "Tham gia cuộc họp video",
|
||||
"joinVideoShort": "Tham gia",
|
||||
"guests": "%{count} khách",
|
||||
@@ -400,7 +401,7 @@
|
||||
},
|
||||
"mobile": {
|
||||
"comingSoon": {
|
||||
"subtitle": "Vui lòng mở Twake Calendar trên máy tính để sử dụng đầy đủ tính năng. Phiên bản di động được tối ưu hóa hoàn toàn sẽ sớm ra mắt.",
|
||||
"subtitle": "Vui lòng mở Workavia Calendar trên máy tính để sử dụng đầy đủ tính năng. Phiên bản di động được tối ưu hóa hoàn toàn sẽ sớm ra mắt.",
|
||||
"title": "Phiên bản di động sắp ra mắt"
|
||||
}
|
||||
},
|
||||
|
||||
+36
-33
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -1,12 +1,9 @@
|
||||
<svg width="210" height="32" viewBox="0 0 210 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.4703 25V10.3956H0.381649V7.18973H14.121V10.3956H9.03235V25H5.4703ZM15.7673 25L11.6454 11.7695H15.6655L17.3702 19.2753C17.5228 19.9114 17.6755 20.6238 17.7773 21.2853C17.879 20.6238 18.0317 19.8859 18.2098 19.2498L20.1435 11.7695H24.0872L26.1481 19.2498C26.3262 19.8859 26.4789 20.6238 26.5806 21.2853C26.6824 20.6238 26.8351 19.9114 26.9877 19.2753L28.8196 11.7695H32.5598L28.438 25H24.7233L22.5606 17.4688C22.3571 16.8327 22.2044 16.1458 22.1026 15.4842C22.0008 16.1458 21.8736 16.8327 21.6955 17.4688L19.711 25H15.7673ZM38.7027 25.3308C35.0644 25.3308 32.1638 22.2521 32.1638 18.4356C32.1638 14.6192 35.0644 11.4896 38.7027 11.4896C40.9926 11.4896 42.1885 12.431 42.9009 13.8559V11.7695H46.3103V25H42.9772V22.8373C42.2648 24.3385 41.069 25.3308 38.7027 25.3308ZM35.5987 18.4102C35.5987 20.4202 37.0744 22.2267 39.2625 22.2267C41.5269 22.2267 42.9772 20.4965 42.9772 18.4356C42.9772 16.3747 41.5269 14.5937 39.2625 14.5937C37.0744 14.5937 35.5987 16.3493 35.5987 18.4102ZM57.1036 25L51.7859 18.7155V25H48.3511V6.42643H51.7859V17.1126L56.7474 11.7695H61.6325L55.3989 17.9268L61.6579 25H57.1036ZM67.2093 25.3308C63.113 25.3308 60.3651 22.2267 60.3651 18.4102C60.3651 14.5937 63.2402 11.4388 67.2093 11.4388C71.1785 11.4388 73.9263 14.5937 73.9263 18.4102C73.9263 18.7918 73.9009 19.1989 73.8246 19.6315H63.9271C64.3088 21.1835 65.4792 22.2776 67.2093 22.2776C68.685 22.2776 69.83 21.5143 70.4406 20.4965L73.1121 22.5066C72.0435 24.1604 69.83 25.3308 67.2093 25.3308ZM63.9271 17.0617H70.466C70.0844 15.586 68.8377 14.3902 67.1584 14.3902C65.5301 14.3902 64.3088 15.4842 63.9271 17.0617Z" fill="black"/>
|
||||
<path d="M188.81 25.9546C186.816 25.9546 185.157 25.2928 183.833 23.9691C182.527 22.6455 181.874 21.0129 181.874 19.0716C181.874 17.1302 182.527 15.4976 183.833 14.174C185.157 12.8503 186.816 12.1885 188.81 12.1885C190.152 12.1885 191.352 12.5062 192.411 13.1415C193.487 13.7769 194.326 14.6417 194.926 15.7359L192.94 16.8478C192.552 16.0712 191.996 15.4535 191.272 14.9947C190.566 14.5358 189.746 14.3063 188.81 14.3063C187.451 14.3063 186.339 14.7652 185.475 15.683C184.61 16.6007 184.177 17.7302 184.177 19.0716C184.177 20.4129 184.61 21.5424 185.475 22.4601C186.339 23.3779 187.451 23.8368 188.81 23.8368C189.746 23.8368 190.566 23.6073 191.272 23.1484C191.996 22.6896 192.552 22.0719 192.94 21.2953L194.926 22.4072C194.326 23.5014 193.487 24.3662 192.411 25.0016C191.352 25.6369 190.152 25.9546 188.81 25.9546Z" fill="#97A3B7"/>
|
||||
<path d="M171.936 25.9549C170.913 25.9549 170.039 25.7696 169.315 25.3989C168.609 25.0107 168.071 24.49 167.7 23.837C167.33 23.1664 167.145 22.4163 167.145 21.5868C167.145 20.1925 167.55 19.1865 168.362 18.5688C169.192 17.9511 170.374 17.6422 171.91 17.6422H176.463V16.6892C176.463 15.4891 176.19 14.6596 175.642 14.2007C175.113 13.7418 174.31 13.5124 173.233 13.5124C172.227 13.5124 171.495 13.7065 171.036 14.0948C170.577 14.4654 170.321 14.889 170.268 15.3655H167.939C167.992 14.6066 168.248 13.936 168.706 13.3536C169.165 12.7535 169.792 12.277 170.586 11.924C171.38 11.571 172.307 11.3945 173.366 11.3945C174.478 11.3945 175.44 11.571 176.251 11.924C177.063 12.2593 177.69 12.7976 178.131 13.5389C178.572 14.2625 178.793 15.2243 178.793 16.4245V22.1162C178.793 22.8575 178.793 23.5105 178.793 24.0753C178.81 24.6224 178.855 25.1607 178.925 25.6901H176.887C176.834 25.2842 176.798 24.9048 176.781 24.5518C176.763 24.1812 176.754 23.7664 176.754 23.3075C176.384 24.0488 175.784 24.6753 174.954 25.1872C174.125 25.699 173.119 25.9549 171.936 25.9549ZM172.439 23.837C173.18 23.837 173.851 23.7046 174.451 23.4399C175.069 23.1752 175.554 22.7869 175.907 22.2751C176.278 21.7633 176.463 21.1367 176.463 20.3955V19.4424H172.307C171.442 19.4424 170.763 19.6189 170.268 19.9719C169.774 20.3072 169.527 20.8455 169.527 21.5868C169.527 22.2398 169.757 22.7781 170.215 23.2016C170.692 23.6252 171.433 23.837 172.439 23.837Z" fill="#97A3B7"/>
|
||||
<path d="M161.561 25.69V5.83496H163.838V25.69H161.561Z" fill="#97A3B7"/>
|
||||
<path d="M147.118 31.2228H144.841V12.4532H147.118V14.4917C147.577 13.821 148.221 13.2739 149.05 12.8503C149.897 12.4091 150.806 12.1885 151.777 12.1885C153.612 12.1885 155.148 12.8591 156.383 14.2005C157.636 15.5418 158.263 17.1655 158.263 19.0716C158.263 20.9776 157.636 22.6013 156.383 23.9426C155.148 25.284 153.612 25.9546 151.777 25.9546C150.806 25.9546 149.897 25.7428 149.05 25.3193C148.221 24.878 147.577 24.3221 147.118 23.6514V31.2228ZM148.203 22.4866C149.05 23.4044 150.127 23.8632 151.433 23.8632C152.739 23.8632 153.815 23.4044 154.663 22.4866C155.51 21.5689 155.933 20.4305 155.933 19.0716C155.933 17.7126 155.51 16.5742 154.663 15.6565C153.815 14.7387 152.739 14.2799 151.433 14.2799C150.127 14.2799 149.05 14.7387 148.203 15.6565C147.356 16.5742 146.932 17.7126 146.932 19.0716C146.932 20.4305 147.356 21.5689 148.203 22.4866Z" fill="#97A3B7"/>
|
||||
<path d="M140.062 25.69L132.887 19.0716V25.69H130.611V5.83496H132.887V17.6156L138.659 12.4533H141.677L135.164 18.3039L143.133 25.69H140.062Z" fill="#97A3B7"/>
|
||||
<path d="M120.649 25.6903V12.4537H122.926V15.0216C123.173 14.2097 123.65 13.5567 124.356 13.0625C125.079 12.5684 125.838 12.3213 126.632 12.3213C127.021 12.3213 127.365 12.3566 127.665 12.4272V14.7833C127.347 14.6421 126.932 14.5715 126.42 14.5715C125.503 14.5715 124.691 14.9686 123.985 15.7628C123.279 16.557 122.926 17.6777 122.926 19.1249V25.6903H120.649Z" fill="#97A3B7"/>
|
||||
<path d="M110.517 25.9546C108.523 25.9546 106.864 25.2928 105.54 23.9691C104.234 22.6278 103.581 20.9953 103.581 19.0716C103.581 17.1478 104.234 15.5241 105.54 14.2005C106.864 12.8591 108.523 12.1885 110.517 12.1885C112.494 12.1885 114.135 12.8591 115.441 14.2005C116.765 15.5241 117.427 17.1478 117.427 19.0716C117.427 20.9953 116.765 22.6278 115.441 23.9691C114.135 25.2928 112.494 25.9546 110.517 25.9546ZM107.182 22.4601C108.047 23.3779 109.158 23.8368 110.517 23.8368C111.876 23.8368 112.979 23.3779 113.827 22.4601C114.674 21.5424 115.097 20.4129 115.097 19.0716C115.097 17.7302 114.674 16.6007 113.827 15.683C112.979 14.7652 111.876 14.3063 110.517 14.3063C109.158 14.3063 108.047 14.7652 107.182 15.683C106.335 16.6007 105.911 17.7302 105.911 19.0716C105.911 20.4129 106.335 21.5424 107.182 22.4601Z" fill="#97A3B7"/>
|
||||
<path d="M95.4325 25.6899L90.8261 12.9298L86.2462 25.6899H84.0489L78.9131 6.89385H81.481L85.3991 21.5601L90.8261 6.57617L96.2531 21.5601L100.198 6.89385H102.766L97.6298 25.6899H95.4325Z" fill="#97A3B7"/>
|
||||
<path d="M203.673 25.9546C201.643 25.9546 199.993 25.3016 198.722 23.9956C197.452 22.6896 196.816 21.0482 196.816 19.0716C196.816 17.1125 197.46 15.48 198.749 14.174C200.037 12.8503 201.687 12.1885 203.699 12.1885C205.482 12.1885 206.973 12.7885 208.173 13.9887C209.391 15.1711 210 16.786 210 18.8333C210 19.098 209.991 19.3275 209.974 19.5216H199.119C199.155 20.757 199.596 21.7895 200.443 22.619C201.308 23.4485 202.393 23.8632 203.699 23.8632C205.552 23.8632 206.929 23.0955 207.829 21.56L209.629 22.8043C208.341 24.9045 206.356 25.9546 203.673 25.9546ZM199.278 17.6685H207.67C207.494 16.6095 207.026 15.7712 206.267 15.1535C205.526 14.5181 204.644 14.2005 203.62 14.2005C202.579 14.2005 201.643 14.5181 200.814 15.1535C200.002 15.7712 199.49 16.6095 199.278 17.6685Z" fill="#97A3B7"/>
|
||||
<text x="105" y="25"
|
||||
text-anchor="middle"
|
||||
font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Plus Jakarta Sans', system-ui, sans-serif"
|
||||
font-weight="800"
|
||||
font-size="28"
|
||||
letter-spacing="-0.5"
|
||||
fill="#000000">Workavia</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 393 B |
@@ -25,7 +25,7 @@ export function generateMeetingId(): string {
|
||||
*/
|
||||
export function generateMeetingLink(baseUrl?: string): string {
|
||||
const base =
|
||||
baseUrl || window.VIDEO_CONFERENCE_BASE_URL || 'https://meet.linagora.com'
|
||||
baseUrl || window.VIDEO_CONFERENCE_BASE_URL || 'https://meet.jit.si'
|
||||
const meetingId = generateMeetingId()
|
||||
return `${base}/${meetingId}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user