mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-22 09:46:30 -05:00
Add toasts (#1073)
Signed-off-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>
This commit is contained in:
parent
0b4a0afa16
commit
a86789dbef
11 changed files with 320 additions and 13 deletions
|
@ -32,6 +32,8 @@ import { Splitter } from './splitter/splitter'
|
||||||
import { DualScrollState, ScrollState } from './synced-scroll/scroll-props'
|
import { DualScrollState, ScrollState } from './synced-scroll/scroll-props'
|
||||||
import { RendererType } from '../render-page/rendering-message'
|
import { RendererType } from '../render-page/rendering-message'
|
||||||
import { useEditorModeFromUrl } from './hooks/useEditorModeFromUrl'
|
import { useEditorModeFromUrl } from './hooks/useEditorModeFromUrl'
|
||||||
|
import { UiNotifications } from '../notifications/ui-notifications'
|
||||||
|
import { useNotificationTest } from './use-notification-test'
|
||||||
|
|
||||||
export interface EditorPagePathParams {
|
export interface EditorPagePathParams {
|
||||||
id: string
|
id: string
|
||||||
|
@ -82,8 +84,11 @@ export const EditorPage: React.FC = () => {
|
||||||
scrollSource.current = ScrollSource.EDITOR
|
scrollSource.current = ScrollSource.EDITOR
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useNotificationTest()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
<UiNotifications/>
|
||||||
<MotdBanner/>
|
<MotdBanner/>
|
||||||
<div className={ 'd-flex flex-column vh-100' }>
|
<div className={ 'd-flex flex-column vh-100' }>
|
||||||
<AppBar mode={ AppBarMode.EDITOR }/>
|
<AppBar mode={ AppBarMode.EDITOR }/>
|
||||||
|
|
24
src/components/editor-page/use-notification-test.tsx
Normal file
24
src/components/editor-page/use-notification-test.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { DEFAULT_DURATION_IN_SECONDS, dispatchUiNotification } from '../../redux/ui-notifications/methods'
|
||||||
|
|
||||||
|
const localStorageKey = 'dontshowtestnotification'
|
||||||
|
|
||||||
|
export const useNotificationTest = (): void => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.localStorage.getItem(localStorageKey)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.debug('[Notifications] Dispatched test notification')
|
||||||
|
dispatchUiNotification('Notification-Test!', 'It Works!', DEFAULT_DURATION_IN_SECONDS, 'info-circle', [{
|
||||||
|
label: 'Don\'t show again', onClick: () => {
|
||||||
|
window.localStorage.setItem(localStorageKey, '1')
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
}, [])
|
||||||
|
}
|
|
@ -4,26 +4,30 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react'
|
import React, { Fragment } from 'react'
|
||||||
import { Container } from 'react-bootstrap'
|
import { Container } from 'react-bootstrap'
|
||||||
import { useDocumentTitle } from '../../hooks/common/use-document-title'
|
import { useDocumentTitle } from '../../hooks/common/use-document-title'
|
||||||
import { MotdBanner } from '../common/motd-banner/motd-banner'
|
import { MotdBanner } from '../common/motd-banner/motd-banner'
|
||||||
import { Footer } from './footer/footer'
|
import { Footer } from './footer/footer'
|
||||||
import { HeaderBar } from './navigation/header-bar/header-bar'
|
import { HeaderBar } from './navigation/header-bar/header-bar'
|
||||||
|
import { UiNotifications } from '../notifications/ui-notifications'
|
||||||
|
|
||||||
export const LandingLayout: React.FC = ({ children }) => {
|
export const LandingLayout: React.FC = ({ children }) => {
|
||||||
useDocumentTitle()
|
useDocumentTitle()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="text-light d-flex flex-column mvh-100">
|
<Fragment>
|
||||||
<MotdBanner/>
|
<UiNotifications/>
|
||||||
<HeaderBar/>
|
<Container className="text-light d-flex flex-column mvh-100">
|
||||||
<div className={ 'd-flex flex-column justify-content-between flex-fill text-center' }>
|
<MotdBanner/>
|
||||||
<main>
|
<HeaderBar/>
|
||||||
{ children }
|
<div className={ 'd-flex flex-column justify-content-between flex-fill text-center' }>
|
||||||
</main>
|
<main>
|
||||||
<Footer/>
|
{ children }
|
||||||
</div>
|
</main>
|
||||||
</Container>
|
<Footer/>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
22
src/components/notifications/notifications.scss
Normal file
22
src/components/notifications/notifications.scss
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
/*!
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
.notifications-area {
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
z-index: 2000;
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
min-width: 250px;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
border-radius: 0;
|
||||||
|
height: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
92
src/components/notifications/ui-notification-toast.tsx
Normal file
92
src/components/notifications/ui-notification-toast.tsx
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Button, ProgressBar, Toast } from 'react-bootstrap'
|
||||||
|
import { UiNotification } from '../../redux/ui-notifications/types'
|
||||||
|
import { ForkAwesomeIcon } from '../common/fork-awesome/fork-awesome-icon'
|
||||||
|
import { ShowIf } from '../common/show-if/show-if'
|
||||||
|
import { IconName } from '../common/fork-awesome/types'
|
||||||
|
import { dismissUiNotification } from '../../redux/ui-notifications/methods'
|
||||||
|
|
||||||
|
const STEPS_PER_SECOND = 10
|
||||||
|
|
||||||
|
export interface UiNotificationProps extends UiNotification {
|
||||||
|
notificationId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UiNotificationToast: React.FC<UiNotificationProps> = ({ title, content, date, icon, dismissed, notificationId, durationInSecond, buttons }) => {
|
||||||
|
const [eta, setEta] = useState<number>()
|
||||||
|
const interval = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||||
|
|
||||||
|
const deleteInterval = useCallback(() => {
|
||||||
|
if (interval.current) {
|
||||||
|
clearInterval(interval.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const dismissThisNotification = useCallback(() => {
|
||||||
|
console.debug(`[Notifications] Dismissed notification ${ notificationId }`)
|
||||||
|
dismissUiNotification(notificationId)
|
||||||
|
}, [notificationId])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (dismissed || !!interval.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.debug(`[Notifications] Show notification ${ notificationId }`)
|
||||||
|
setEta(durationInSecond * STEPS_PER_SECOND)
|
||||||
|
interval.current = setInterval(() => setEta((lastETA) => {
|
||||||
|
if (lastETA === undefined) {
|
||||||
|
return
|
||||||
|
} else if (lastETA <= 0) {
|
||||||
|
return 0
|
||||||
|
} else {
|
||||||
|
return lastETA - 1
|
||||||
|
}
|
||||||
|
}), 1000 / STEPS_PER_SECOND)
|
||||||
|
return () => {
|
||||||
|
deleteInterval()
|
||||||
|
}
|
||||||
|
}, [deleteInterval, dismissThisNotification, dismissed, durationInSecond, notificationId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (eta === 0) {
|
||||||
|
dismissThisNotification()
|
||||||
|
}
|
||||||
|
}, [dismissThisNotification, eta])
|
||||||
|
|
||||||
|
const buttonsDom = useMemo(() => buttons?.map(button => {
|
||||||
|
const buttonClick = () => {
|
||||||
|
button.onClick()
|
||||||
|
dismissThisNotification()
|
||||||
|
}
|
||||||
|
return <Button key={ button.label } size={ 'sm' } onClick={ buttonClick }
|
||||||
|
variant={ 'link' }>{ button.label }</Button>
|
||||||
|
}
|
||||||
|
), [buttons, dismissThisNotification])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toast show={ !dismissed && eta !== undefined } onClose={ dismissThisNotification }>
|
||||||
|
<Toast.Header>
|
||||||
|
<strong className="mr-auto">
|
||||||
|
<ShowIf condition={ !!icon }>
|
||||||
|
<ForkAwesomeIcon icon={ icon as IconName } fixedWidth={ true } className={ 'mr-1' }/>
|
||||||
|
</ShowIf>
|
||||||
|
{ title }
|
||||||
|
</strong>
|
||||||
|
<small>{ date.toRelative({ style: 'short' }) }</small>
|
||||||
|
</Toast.Header>
|
||||||
|
<Toast.Body>{ content }</Toast.Body>
|
||||||
|
<ProgressBar variant={ 'info' } now={ eta } max={ durationInSecond * STEPS_PER_SECOND } min={ 0 }/>
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
buttonsDom
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</Toast>
|
||||||
|
)
|
||||||
|
}
|
34
src/components/notifications/ui-notifications.tsx
Normal file
34
src/components/notifications/ui-notifications.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { UiNotificationToast } from './ui-notification-toast'
|
||||||
|
import './notifications.scss'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import { ApplicationState } from '../../redux'
|
||||||
|
import equal from 'fast-deep-equal'
|
||||||
|
|
||||||
|
export const UiNotifications: React.FC = () => {
|
||||||
|
const notifications = useSelector((state: ApplicationState) => state.uiNotifications, equal)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={ 'notifications-area' }
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true">
|
||||||
|
{
|
||||||
|
notifications.map((notification, notificationIndex) =>
|
||||||
|
<UiNotificationToast
|
||||||
|
key={ notificationIndex }
|
||||||
|
notificationId={ notificationIndex }
|
||||||
|
{ ...notification }/>)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,8 @@ import { NoteDetailsReducer } from './note-details/reducers'
|
||||||
import { NoteDetails } from './note-details/types'
|
import { NoteDetails } from './note-details/types'
|
||||||
import { UserReducer } from './user/reducers'
|
import { UserReducer } from './user/reducers'
|
||||||
import { MaybeUserState } from './user/types'
|
import { MaybeUserState } from './user/types'
|
||||||
|
import { UiNotificationState } from './ui-notifications/types'
|
||||||
|
import { UiNotificationReducer } from './ui-notifications/reducers'
|
||||||
|
|
||||||
export interface ApplicationState {
|
export interface ApplicationState {
|
||||||
user: MaybeUserState;
|
user: MaybeUserState;
|
||||||
|
@ -28,6 +30,7 @@ export interface ApplicationState {
|
||||||
editorConfig: EditorConfig;
|
editorConfig: EditorConfig;
|
||||||
darkMode: DarkModeConfig;
|
darkMode: DarkModeConfig;
|
||||||
noteDetails: NoteDetails;
|
noteDetails: NoteDetails;
|
||||||
|
uiNotifications: UiNotificationState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const allReducers: Reducer<ApplicationState> = combineReducers<ApplicationState>({
|
export const allReducers: Reducer<ApplicationState> = combineReducers<ApplicationState>({
|
||||||
|
@ -37,7 +40,8 @@ export const allReducers: Reducer<ApplicationState> = combineReducers<Applicatio
|
||||||
apiUrl: ApiUrlReducer,
|
apiUrl: ApiUrlReducer,
|
||||||
editorConfig: EditorConfigReducer,
|
editorConfig: EditorConfigReducer,
|
||||||
darkMode: DarkModeConfigReducer,
|
darkMode: DarkModeConfigReducer,
|
||||||
noteDetails: NoteDetailsReducer
|
noteDetails: NoteDetailsReducer,
|
||||||
|
uiNotifications: UiNotificationReducer
|
||||||
})
|
})
|
||||||
|
|
||||||
export const store = createStore(allReducers)
|
export const store = createStore(allReducers)
|
||||||
|
|
39
src/redux/ui-notifications/methods.ts
Normal file
39
src/redux/ui-notifications/methods.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { store } from '../index'
|
||||||
|
import {
|
||||||
|
DismissUiNotificationAction,
|
||||||
|
DispatchUiNotificationAction,
|
||||||
|
UiNotificationActionType,
|
||||||
|
UiNotificationButton
|
||||||
|
} from './types'
|
||||||
|
import { DateTime } from 'luxon'
|
||||||
|
import { IconName } from '../../components/common/fork-awesome/types'
|
||||||
|
|
||||||
|
export const DEFAULT_DURATION_IN_SECONDS = 10
|
||||||
|
|
||||||
|
export const dispatchUiNotification = (title: string, content: string, durationInSecond = DEFAULT_DURATION_IN_SECONDS, icon?: IconName, buttons?: UiNotificationButton[]): void => {
|
||||||
|
store.dispatch({
|
||||||
|
type: UiNotificationActionType.DISPATCH_NOTIFICATION,
|
||||||
|
notification: {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
date: DateTime.now(),
|
||||||
|
dismissed: false,
|
||||||
|
icon,
|
||||||
|
durationInSecond,
|
||||||
|
buttons: buttons
|
||||||
|
}
|
||||||
|
} as DispatchUiNotificationAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dismissUiNotification = (notificationId: number): void => {
|
||||||
|
store.dispatch({
|
||||||
|
type: UiNotificationActionType.DISMISS_NOTIFICATION,
|
||||||
|
notificationId
|
||||||
|
} as DismissUiNotificationAction)
|
||||||
|
}
|
36
src/redux/ui-notifications/reducers.ts
Normal file
36
src/redux/ui-notifications/reducers.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import { Reducer } from 'redux'
|
||||||
|
import {
|
||||||
|
DismissUiNotificationAction,
|
||||||
|
DispatchUiNotificationAction,
|
||||||
|
UiNotificationAction,
|
||||||
|
UiNotificationActionType,
|
||||||
|
UiNotificationState
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export const UiNotificationReducer: Reducer<UiNotificationState, UiNotificationAction> = (state: UiNotificationState = [], action: UiNotificationAction) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case UiNotificationActionType.DISPATCH_NOTIFICATION:
|
||||||
|
return state.concat((action as DispatchUiNotificationAction).notification)
|
||||||
|
case UiNotificationActionType.DISMISS_NOTIFICATION:
|
||||||
|
return dismissNotification(state, (action as DismissUiNotificationAction).notificationId)
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismissNotification = (notificationState: UiNotificationState, notificationIndex: number): UiNotificationState => {
|
||||||
|
const newArray = [...notificationState]
|
||||||
|
const oldNotification = newArray[notificationIndex]
|
||||||
|
newArray[notificationIndex] = {
|
||||||
|
...oldNotification,
|
||||||
|
dismissed: true
|
||||||
|
}
|
||||||
|
return newArray
|
||||||
|
}
|
45
src/redux/ui-notifications/types.ts
Normal file
45
src/redux/ui-notifications/types.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Action } from 'redux'
|
||||||
|
import { DateTime } from 'luxon'
|
||||||
|
import { IconName } from '../../components/common/fork-awesome/types'
|
||||||
|
|
||||||
|
export enum UiNotificationActionType {
|
||||||
|
DISPATCH_NOTIFICATION = 'notification/dispatch',
|
||||||
|
DISMISS_NOTIFICATION = 'notification/dismiss'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UiNotificationButton {
|
||||||
|
label: string,
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UiNotification {
|
||||||
|
title: string
|
||||||
|
date: DateTime
|
||||||
|
content: string
|
||||||
|
dismissed: boolean
|
||||||
|
icon?: IconName
|
||||||
|
durationInSecond: number
|
||||||
|
buttons?: UiNotificationButton[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UiNotificationAction extends Action<UiNotificationActionType> {
|
||||||
|
type: UiNotificationActionType
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DispatchUiNotificationAction extends UiNotificationAction {
|
||||||
|
type: UiNotificationActionType.DISPATCH_NOTIFICATION
|
||||||
|
notification: UiNotification
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DismissUiNotificationAction extends UiNotificationAction {
|
||||||
|
type: UiNotificationActionType.DISMISS_NOTIFICATION
|
||||||
|
notificationId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UiNotificationState = UiNotification[]
|
|
@ -1,4 +1,4 @@
|
||||||
/*
|
/*!
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
@ -12,3 +12,5 @@ $dark: #222222 !default;
|
||||||
@import "../../node_modules/bootstrap/scss/mixins";
|
@import "../../node_modules/bootstrap/scss/mixins";
|
||||||
@import "../../node_modules/bootstrap/scss/variables";
|
@import "../../node_modules/bootstrap/scss/variables";
|
||||||
|
|
||||||
|
$toast-background-color: $white;
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue