11 Commits

Author SHA1 Message Date
stanig2106 35d0578e33 fix: persist OIDC token in localStorage instead of sessionStorage
Bug: opening calendar in a new browser tab forced a re-login every time,
even when the user was authenticated in another tab.

Root cause: tokenSet/userData/redirectState were stored in sessionStorage,
which is per-tab by design. New tab → empty sessionStorage → app
redirects to OIDC. With Dex's password-DB connector (no IdP-side session
cookie), Dex re-prompts for credentials.

Mail (tmail-flutter) has the same OIDC flow but persists tokens in
IndexedDB (Hive box `tokenoidccache`) which IS shared across tabs —
hence mail SSO worked but calendar didn't.

Fix: replace sessionStorage with localStorage for the auth-related keys
(tokenSet, userData, redirectState). localStorage is shared across tabs
of the same origin. Other sessionStorage usages (e.g. eventUpdateModalReopen)
are unchanged — they are genuinely per-tab.

Files touched:
- src/features/User/useInitializeApp.ts (load + setup redirect state)
- src/features/User/LoginCallback.tsx (save tokens after callback,
  read pending redirectState, removeItem on completion)
- src/utils/apiUtils.ts (write redirectState during 401-driven SSO redirect)
- src/components/Calendar/hooks/useUtilMenus.ts (logout cleanup)

Verified: open clean tab → login alice → open another tab on
calendar.workavia.local → loaded silently with avatar visible, no
login form prompted.
2026-04-29 21:32:32 +02:00
stanig2106 faefd31ac3 fix: ignore "not found in store" race on boot in getCalendarDetailAsync.rejected
On hard reload, useCalendarLoader dispatches getCalendarDetailAsync(calId)
for selected calendars (cached in localStorage) BEFORE getCalendarsListAsync
has fulfilled. The detail thunk sees an empty state.calendars.list and
rejects with "Calendar X not found in store". The reject handler then sets
state.calendars.error, and HandleLogin redirects to /error.

Result: ~50% of reloads land on /error.

Treat this specific race-condition error like AbortError: ignore it. The
detail fetch is naturally re-issued by useCalendarLoader once the list is
loaded, so no functional regression.
2026-04-29 18:38:33 +02:00
stanig2106 0089fba30d rebrand: replace Twake logos with Workavia wordmark
- src/static/header-logo.svg: rebuild with SVG <text> "Workavia" (black) +
  "Calendar" (orange gradient), keeping the original calendar icon
  Style matches Workavia Mail logo (brand name in black, app suffix in color)
- src/static/twake-workplace.svg: simplify to centered "Workavia" text
- Loading.tsx alt: "twake workplace" -> "Workavia"
2026-04-29 18:26:27 +02:00
stanig2106 fe15fc076a rebrand: Workavia Calendar
User-facing strings:
- "Twake Calendar" -> "Workavia Calendar" in 4 locales (en/fr/ru/vi)
- public/index.html <title>
- public/manifest.appdata (name + short_name)
- VALARM description string (sent to participants in iCal)

Defaults / neutralization:
- Video conference fallback URL: meet.linagora.com -> meet.jit.si
  (still overridable via window.VIDEO_CONFERENCE_BASE_URL or .env.js)

Kept intact (design system / non user-facing):
- @linagora/twake-mui package + TwakeMuiThemeProvider class
  (primary colors preserved per requirement)
- public/calendar.svg, app-*.svg icons (no embedded text)
2026-04-29 18:18:34 +02:00
Camille Moussu d16e7941ad [#805] fix filter end adornment alignments (#853)
Co-authored-by: Camille Moussu <cmoussu@linagora.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-29 18:01:54 +02:00
Camille Moussu 1ceaefa7bc [#817] removed deprecated border on sidebar and fix small visual bug cropping the bottom of the desktop sidebar (#852)
Co-authored-by: Camille Moussu <cmoussu@linagora.com>
2026-04-29 17:59:53 +02:00
lethemanh c429306e77 #843 missing back button in settings on mobile (#849)
Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
2026-04-29 17:38:47 +02:00
Camille Moussu d66bc59967 [#692] split search result page to mobile and desktop (#823)
Co-authored-by: Camille Moussu <cmoussu@linagora.com>
2026-04-29 09:55:17 +02:00
lethemanh 29d82e5ade #845 set dynamic name for edit tooltip 2026-04-29 09:53:34 +02:00
Benoit TELLIER 2f849b5323 ISSUE-845 Edit in XXX calendar 2026-04-29 09:53:34 +02:00
lethemanh c910f3e3a9 #693 open calendar menu on mobile (#842)
Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
2026-04-28 16:09:21 +02:00
35 changed files with 1299 additions and 655 deletions
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"short_name": "Twake Calendar",
"name": "Twake Calendar App",
"short_name": "Workavia Calendar",
"name": "Workavia Calendar",
"icons": [
{
"src": "favicon.ico",
+2 -1
View File
@@ -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%'
-1
View File
@@ -38,7 +38,6 @@
margin-left 25%
.sidebar
border-right 2px solid #f3f4f6
width 270px
height 100%
flex-direction column
+198 -196
View File
@@ -416,212 +416,214 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
<div className="calendar">
<ImportAlert />
{menubarProps?.isIframe && <Menubar {...menubarProps} />}
{(isTablet || isMobile) && (
<Fab
color="primary"
aria-label={t('event.createEvent')}
onClick={() => eventHandlers.handleDateSelect(null)}
sx={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 20,
borderRadius: '16px'
}}
>
<AddIcon />
</Fab>
)}
{view === 'calendar' && (
<div
ref={calendarWrapperRef}
className={isTablet || isMobile ? 'calendar-swipe-container' : ''}
style={{
height: '100%',
...(isTablet || isMobile ? { touchAction: 'pan-y' } : {})
}}
{...(isTablet || isMobile ? handlers : {})}
>
<FullCalendar
key={hiddenDays.join(',')}
ref={ref => {
if (ref) {
calendarRef.current = ref.getApi()
<>
{' '}
{(isTablet || isMobile) && (
<Fab
color="primary"
aria-label={t('event.createEvent')}
onClick={() => eventHandlers.handleDateSelect(null)}
sx={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 20,
borderRadius: '16px'
}}
>
<AddIcon />
</Fab>
)}
<div
ref={calendarWrapperRef}
className={isTablet || isMobile ? 'calendar-swipe-container' : ''}
style={{
height: '100%',
...(isTablet || isMobile ? { touchAction: 'pan-y' } : {})
}}
{...(isTablet || isMobile ? handlers : {})}
>
<FullCalendar
key={hiddenDays.join(',')}
ref={ref => {
calendarRef.current = ref ? ref.getApi() : null
}}
plugins={[
dayGridPlugin,
timeGridPlugin,
interactionPlugin,
momentTimezonePlugin
]}
initialView={
currentView ||
(isTablet
? CALENDAR_VIEWS.timeGridDay
: CALENDAR_VIEWS.timeGridWeek)
}
}}
plugins={[
dayGridPlugin,
timeGridPlugin,
interactionPlugin,
momentTimezonePlugin
]}
initialView={
currentView ||
(isTablet
? CALENDAR_VIEWS.timeGridDay
: CALENDAR_VIEWS.timeGridWeek)
}
initialDate={selectedDate}
firstDay={1}
editable={true}
locale={localeMap[lang]}
hiddenDays={hiddenDays}
selectable={true}
timeZone={timezone}
height="100%"
select={eventHandlers.handleDateSelect}
nowIndicator
slotLabelClassNames={arg => [
updateSlotLabelVisibility(new Date(), arg, timezone)
]}
nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
headerToolbar={false}
views={{
timeGridWeek: {
titleFormat: { month: 'long', year: 'numeric' }
initialDate={selectedDate}
firstDay={1}
editable={true}
locale={localeMap[lang]}
hiddenDays={hiddenDays}
selectable={true}
timeZone={timezone}
height="100%"
select={eventHandlers.handleDateSelect}
nowIndicator
slotLabelClassNames={arg => [
updateSlotLabelVisibility(new Date(), arg, timezone)
]}
nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
headerToolbar={false}
views={{
timeGridWeek: {
titleFormat: { month: 'long', year: 'numeric' }
}
}}
dayMaxEvents={true}
moreLinkClick={handleMoreLinkClick}
events={eventToFullCalendarFormat(
filteredEvents,
filteredTempEvents,
userId,
userData?.email,
isPending,
calendars
)}
eventOrder={(a: EventApi, b: EventApi) =>
a.extendedProps.priority - b.extendedProps.priority
}
}}
dayMaxEvents={true}
moreLinkClick={handleMoreLinkClick}
events={eventToFullCalendarFormat(
filteredEvents,
filteredTempEvents,
userId,
userData?.email,
isPending,
calendars
)}
eventOrder={(a: EventApi, b: EventApi) =>
a.extendedProps.priority - b.extendedProps.priority
}
weekNumbers={
currentView === CALENDAR_VIEWS.timeGridWeek ||
currentView === CALENDAR_VIEWS.timeGridDay
}
weekNumberFormat={{ week: 'long' }}
weekNumberContent={arg => {
return (
<div className="weekSelector">
{displayWeekNumbers && (
<>
<div>
{t('menubar.views.week')} {arg.num}
</div>
<TimezoneSelector
value={timezone}
referenceDate={
calendarRef.current?.getDate() ?? new Date()
}
onChange={(newTimezone: string) =>
dispatch(setTimeZone(newTimezone))
}
/>
</>
)}
</div>
)
}}
dayCellContent={arg => {
const month = arg.date.toLocaleDateString(t('locale'), {
month: 'short',
timeZone: timezone
})
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
weekNumbers={
currentView === CALENDAR_VIEWS.timeGridWeek ||
currentView === CALENDAR_VIEWS.timeGridDay
}
weekNumberFormat={{ week: 'long' }}
weekNumberContent={arg => {
return (
<span
className={`fc-daygrid-day-number ${
arg.isToday ? 'current-date' : ''
}`}
>
{arg.dayNumberText === '1' ? month : ''}{' '}
{arg.dayNumberText}
</span>
<div className="weekSelector">
{displayWeekNumbers && (
<>
<div>
{t('menubar.views.week')} {arg.num}
</div>
<TimezoneSelector
value={timezone}
referenceDate={
calendarRef.current?.getDate() ?? new Date()
}
onChange={(newTimezone: string) =>
dispatch(setTimeZone(newTimezone))
}
/>
</>
)}
</div>
)
}
}}
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
scrollTime="12:00:00"
unselectAuto={false}
allDayText=""
slotLabelFormat={{
hour: '2-digit',
minute: '2-digit',
hour12: false
}}
datesSet={arg => {
onViewChange?.(arg.view.type)
const calendarCurrentDate =
calendarRef.current?.getDate() || new Date(arg.start)
setDisplayedDateAndRange(calendarCurrentDate)
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
const start = new Date(arg.start).getTime()
const end = new Date(arg.end).getTime()
const middle = start + (end - start) / 2
setSelectedDate(new Date(middle))
setSelectedMiniDate(calendarCurrentDate)
} else {
setSelectedDate(calendarCurrentDate)
setSelectedMiniDate(calendarCurrentDate)
}
// Always use the calendar's current date for consistency
if (onDateChange) {
onDateChange(calendarCurrentDate)
}
// Notify parent about view change
setCurrentView(arg.view.type)
// Update slot label visibility when view changes
setTimeout(() => {
updateSlotLabelVisibility(new Date())
}, 100)
}}
dayHeaderContent={arg => {
const m = moment.tz(arg.date, timezone)
const date = m.date()
const weekDay = m
.toDate()
.toLocaleDateString(t('locale'), {
weekday: 'short',
}}
dayCellContent={arg => {
const month = arg.date.toLocaleDateString(t('locale'), {
month: 'short',
timeZone: timezone
})
.toUpperCase()
return (
<div
className={`fc-daygrid-day-top ${isTablet || isMobile ? 'fc-daygrid-day-top--mobile' : ''}`}
>
<small>{weekDay}</small>
{arg.view.type !== CALENDAR_VIEWS.dayGridMonth && (
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
return (
<span
className={`fc-daygrid-day-number ${arg.isToday ? 'current-date' : ''}`}
className={`fc-daygrid-day-number ${
arg.isToday ? 'current-date' : ''
}`}
>
{date}
{arg.dayNumberText === '1' ? month : ''}{' '}
{arg.dayNumberText}
</span>
)}
</div>
)
}}
dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount}
dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount}
viewDidMount={viewHandlers.handleViewDidMount}
viewWillUnmount={viewHandlers.handleViewWillUnmount}
eventClick={eventHandlers.handleEventClick}
eventAllow={eventHandlers.handleEventAllow}
eventDrop={arg => {
void eventHandlers.handleEventDrop(arg)
}}
eventResize={arg => {
void eventHandlers.handleEventResize(arg)
}}
eventContent={viewHandlers.handleEventContent}
eventDidMount={viewHandlers.handleEventDidMount}
/>
</div>
)
}
}}
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
scrollTime="12:00:00"
unselectAuto={false}
allDayText=""
slotLabelFormat={{
hour: '2-digit',
minute: '2-digit',
hour12: false
}}
datesSet={arg => {
onViewChange?.(arg.view.type)
const calendarCurrentDate =
calendarRef.current?.getDate() || new Date(arg.start)
setDisplayedDateAndRange(calendarCurrentDate)
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
const start = new Date(arg.start).getTime()
const end = new Date(arg.end).getTime()
const middle = start + (end - start) / 2
setSelectedDate(new Date(middle))
setSelectedMiniDate(calendarCurrentDate)
} else {
setSelectedDate(calendarCurrentDate)
setSelectedMiniDate(calendarCurrentDate)
}
// Always use the calendar's current date for consistency
if (onDateChange) {
onDateChange(calendarCurrentDate)
}
// Notify parent about view change
setCurrentView(arg.view.type)
// Update slot label visibility when view changes
setTimeout(() => {
updateSlotLabelVisibility(new Date())
}, 100)
}}
dayHeaderContent={arg => {
const m = moment.tz(arg.date, timezone)
const date = m.date()
const weekDay = m
.toDate()
.toLocaleDateString(t('locale'), {
weekday: 'short',
timeZone: timezone
})
.toUpperCase()
return (
<div
className={`fc-daygrid-day-top ${isTablet || isMobile ? 'fc-daygrid-day-top--mobile' : ''}`}
>
<small>{weekDay}</small>
{arg.view.type !== CALENDAR_VIEWS.dayGridMonth && (
<span
className={`fc-daygrid-day-number ${arg.isToday ? 'current-date' : ''}`}
>
{date}
</span>
)}
</div>
)
}}
dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount}
dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount}
viewDidMount={viewHandlers.handleViewDidMount}
viewWillUnmount={viewHandlers.handleViewWillUnmount}
eventClick={eventHandlers.handleEventClick}
eventAllow={eventHandlers.handleEventAllow}
eventDrop={arg => {
void eventHandlers.handleEventDrop(arg)
}}
eventResize={arg => {
void eventHandlers.handleEventResize(arg)
}}
eventContent={viewHandlers.handleEventContent}
eventDidMount={viewHandlers.handleEventDidMount}
/>
</div>
</>
)}
{view === 'search' && <SearchResultsPage />}
<EventPopover
+3 -1
View File
@@ -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>
)
+92 -59
View File
@@ -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>
<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>
{!isMobile && (
<IconButton className="MoreBtn" onClick={handleClick}>
<MoreHorizIcon />
</IconButton>
)}
</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
}}
@@ -58,7 +58,7 @@ export const useUtilMenus = (): {
} catch (error) {
console.error('Logout failed:', error)
} finally {
sessionStorage.removeItem('tokenSet')
localStorage.removeItem('tokenSet')
handleUserMenuClose()
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ export function Loading() {
<Box
component="img"
src={twakeLogo}
alt="twake workplace"
alt="Workavia"
sx={{
position: 'absolute',
bottom: '50px',
+6 -1
View File
@@ -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,
+1 -1
View File
@@ -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]])
+74 -247
View File
@@ -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 />}
</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>
)}
<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>
{calendar && calendar.events[eventData.data.uid] && (
{calendar?.events?.[eventData.data.uid] && (
<EventPreviewModal
eventId={eventData.data.uid}
calId={calendar.id}
+109 -23
View File
@@ -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,37 +24,114 @@ 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 = () => {
const FiltersButtons: React.FC = () => (
<Box
sx={{
display: 'flex',
overflowX: 'auto',
gap: 2,
px: 2,
py: 1,
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
backgroundColor: '#FFF',
minHeight: '48px'
}}
>
<SearchInFilter mode="mobile" />
<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: 'flex',
overflowX: 'auto',
gap: 2,
px: 2,
py: 1,
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
backgroundColor: '#FFF',
minHeight: '48px'
}}
>
<SearchInFilter mode="mobile" />
<OrganizersFilter mode="mobile" />
<AttendeesFilter mode="mobile" />
</Box>
<>
<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)}
/>
)}
</>
)
}
+73
View File
@@ -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>
)
}
+39
View File
@@ -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>
)
}
+46
View File
@@ -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 }
}
+54 -42
View File
@@ -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,49 +50,57 @@ const SettingsPage: React.FC<{ isInIframe?: boolean }> = ({ isInIframe }) => {
}
return (
<main
className={`main-layout settings-layout${isInIframe ? ' isInIframe' : ''} ${isMobile ? 'settings-layout--mobile' : ''}`}
>
{isMobile ? (
<MobileSettingsPage
activeSettingsSubTab={activeSettingsSubTab}
setLanguageErrorOpen={setLanguageErrorOpen}
setTimeZoneErrorOpen={setTimeZoneErrorOpen}
setAlarmEmailsErrorOpen={setAlarmEmailsErrorOpen}
setHideDeclinedEventsErrorOpen={setHideDeclinedEventsErrorOpen}
setDisplayWeekNumbersErrorOpen={setDisplayWeekNumbersErrorOpen}
<>
{isInIframe && isMobile && menubarProps && <Menubar {...menubarProps} />}
<main
className={`main-layout settings-layout${isInIframe ? ' isInIframe' : ''} ${isMobile ? 'settings-layout--mobile' : ''}`}
>
{isMobile ? (
<MobileSettingsPage
activeSettingsSubTab={activeSettingsSubTab}
setLanguageErrorOpen={setLanguageErrorOpen}
setTimeZoneErrorOpen={setTimeZoneErrorOpen}
setAlarmEmailsErrorOpen={setAlarmEmailsErrorOpen}
setHideDeclinedEventsErrorOpen={setHideDeclinedEventsErrorOpen}
setDisplayWeekNumbersErrorOpen={setDisplayWeekNumbersErrorOpen}
setWorkingDaysErrorOpen={setWorkingDaysErrorOpen}
handleSettingsSubTabChange={handleSettingsSubTabChange}
setActiveSettingsSubTab={setActiveSettingsSubTab}
/>
) : (
<DesktopSettingsPage
activeSettingsSubTab={activeSettingsSubTab}
setLanguageErrorOpen={setLanguageErrorOpen}
setTimeZoneErrorOpen={setTimeZoneErrorOpen}
setAlarmEmailsErrorOpen={setAlarmEmailsErrorOpen}
setHideDeclinedEventsErrorOpen={setHideDeclinedEventsErrorOpen}
setDisplayWeekNumbersErrorOpen={setDisplayWeekNumbersErrorOpen}
setWorkingDaysErrorOpen={setWorkingDaysErrorOpen}
handleSettingsSubTabChange={handleSettingsSubTabChange}
setActiveSettingsSubTab={setActiveSettingsSubTab}
/>
)}
<SettingErrorSnackbars
languageErrorOpen={languageErrorOpen}
timeZoneErrorOpen={timeZoneErrorOpen}
alarmEmailsErrorOpen={alarmEmailsErrorOpen}
hideDeclinedEventsErrorOpen={hideDeclinedEventsErrorOpen}
displayWeekNumbersErrorOpen={displayWeekNumbersErrorOpen}
workingDaysErrorOpen={workingDaysErrorOpen}
handleLanguageErrorClose={handleLanguageErrorClose}
handleTimeZoneErrorClose={handleTimeZoneErrorClose}
handleAlarmEmailsErrorClose={handleAlarmEmailsErrorClose}
handleHideDeclinedEventsErrorClose={
handleHideDeclinedEventsErrorClose
}
handleDisplayWeekNumbersErrorClose={
handleDisplayWeekNumbersErrorClose
}
setWorkingDaysErrorOpen={setWorkingDaysErrorOpen}
handleSettingsSubTabChange={handleSettingsSubTabChange}
setActiveSettingsSubTab={setActiveSettingsSubTab}
/>
) : (
<DesktopSettingsPage
activeSettingsSubTab={activeSettingsSubTab}
setLanguageErrorOpen={setLanguageErrorOpen}
setTimeZoneErrorOpen={setTimeZoneErrorOpen}
setAlarmEmailsErrorOpen={setAlarmEmailsErrorOpen}
setHideDeclinedEventsErrorOpen={setHideDeclinedEventsErrorOpen}
setDisplayWeekNumbersErrorOpen={setDisplayWeekNumbersErrorOpen}
setWorkingDaysErrorOpen={setWorkingDaysErrorOpen}
handleSettingsSubTabChange={handleSettingsSubTabChange}
setActiveSettingsSubTab={setActiveSettingsSubTab}
/>
)}
<SettingErrorSnackbars
languageErrorOpen={languageErrorOpen}
timeZoneErrorOpen={timeZoneErrorOpen}
alarmEmailsErrorOpen={alarmEmailsErrorOpen}
hideDeclinedEventsErrorOpen={hideDeclinedEventsErrorOpen}
displayWeekNumbersErrorOpen={displayWeekNumbersErrorOpen}
workingDaysErrorOpen={workingDaysErrorOpen}
handleLanguageErrorClose={handleLanguageErrorClose}
handleTimeZoneErrorClose={handleTimeZoneErrorClose}
handleAlarmEmailsErrorClose={handleAlarmEmailsErrorClose}
handleHideDeclinedEventsErrorClose={handleHideDeclinedEventsErrorClose}
handleDisplayWeekNumbersErrorClose={handleDisplayWeekNumbersErrorClose}
setWorkingDaysErrorOpen={setWorkingDaysErrorOpen}
/>
</main>
</main>
</>
)
}
+15 -11
View File
@@ -27,26 +27,26 @@ export function CallbackResume() {
const runCallback = async () => {
// Read redirectState inside useEffect to avoid stale closures
const saved = sessionStorage.getItem('redirectState')
? JSON.parse(sessionStorage.getItem('redirectState') ?? '{}')
const saved = localStorage.getItem('redirectState')
? JSON.parse(localStorage.getItem('redirectState') ?? '{}')
: null
// Check if we have saved tokens (already logged in)
const savedToken = sessionStorage.getItem('tokenSet')
? JSON.parse(sessionStorage.getItem('tokenSet') ?? '{}')
const savedToken = localStorage.getItem('tokenSet')
? JSON.parse(localStorage.getItem('tokenSet') ?? '{}')
: null
// If no redirectState but we have saved session, just go home
// This can happen if user refreshes callback page or gets redirected here after already logged in
if (!saved?.code_verifier) {
if (savedToken) {
sessionStorage.removeItem('redirectState')
localStorage.removeItem('redirectState')
dispatch(replace('/'))
return
}
console.warn('Missing redirectState')
sessionStorage.removeItem('redirectState')
localStorage.removeItem('redirectState')
dispatch(replace('/'))
return
}
@@ -60,10 +60,14 @@ export function CallbackResume() {
throw new Error('OAuth callback failed')
}
// IMPORTANT: Save tokens to sessionStorage FIRST before making any API calls
// because API calls will read token from sessionStorage
sessionStorage.setItem('tokenSet', JSON.stringify(data.tokenSet))
sessionStorage.setItem('userData', JSON.stringify(data.userinfo))
// IMPORTANT: Save tokens to localStorage FIRST before making any API calls
// because API calls will read token from localStorage.
// localStorage (not sessionStorage) so the OIDC session is shared
// across browser tabs — otherwise each new tab forces a re-login
// since Dex (with enablePasswordDB connector) does NOT keep an
// IdP-side session cookie.
localStorage.setItem('tokenSet', JSON.stringify(data.tokenSet))
localStorage.setItem('userData', JSON.stringify(data.userinfo))
dispatch(setUserData(data.userinfo))
dispatch(setTokens(data.tokenSet))
@@ -71,7 +75,7 @@ export function CallbackResume() {
await dispatch(getOpenPaasUserDataAsync())
await dispatch(getCalendarsListAsync())
sessionStorage.removeItem('redirectState')
localStorage.removeItem('redirectState')
} catch (e) {
console.error('OIDC callback error:', e)
dispatch(setAppLoading(false))
+5 -5
View File
@@ -23,11 +23,11 @@ export function useInitializeApp() {
hasInitiatedRef.current = true
const initiateLogin = async () => {
const savedToken = sessionStorage.getItem('tokenSet')
? JSON.parse(sessionStorage.getItem('tokenSet') ?? '{}')
const savedToken = localStorage.getItem('tokenSet')
? JSON.parse(localStorage.getItem('tokenSet') ?? '{}')
: null
const savedUser = sessionStorage.getItem('userData')
? JSON.parse(sessionStorage.getItem('userData') ?? '{}')
const savedUser = localStorage.getItem('userData')
? JSON.parse(localStorage.getItem('userData') ?? '{}')
: null
if (savedToken && savedUser) {
@@ -45,7 +45,7 @@ export function useInitializeApp() {
}
const loginurl = await Auth()
sessionStorage.setItem(
localStorage.setItem(
'redirectState',
JSON.stringify({
code_verifier: loginurl.code_verifier,
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
},
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

+7 -10
View File
@@ -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

+3 -3
View File
@@ -35,7 +35,7 @@ const redirectSSO = async (
try {
const loginurl = await Auth()
sessionStorage.setItem(
localStorage.setItem(
'redirectState',
JSON.stringify({
code_verifier: loginurl.code_verifier,
@@ -85,9 +85,9 @@ export const api: KyInstance = ky.extend({
hooks: {
beforeRequest: [
async (request: KyRequest): Promise<KyRequest> => {
const saved = sessionStorage.getItem('tokenSet')
const saved = localStorage.getItem('tokenSet')
? (JSON.parse(
sessionStorage.getItem('tokenSet') ?? '{}'
localStorage.getItem('tokenSet') ?? '{}'
) as TokenEndpointResponse & TokenEndpointResponseHelpers)
: null
const access_token = saved?.access_token as string
+1 -1
View File
@@ -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}`
}