From 232a19d7dfe0e3847b3a9a9a9be83485ffb9031c Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 30 May 2020 16:23:25 +0800 Subject: Merge front end to this repo. But I need to wait for aspnet core support for custom port and package manager for dev server. --- Timeline/ClientApp/src/common/AlertHost.tsx | 75 ++++ Timeline/ClientApp/src/common/AppBar.tsx | 107 ++++++ Timeline/ClientApp/src/common/CollapseButton.tsx | 105 ++++++ Timeline/ClientApp/src/common/FileInput.tsx | 41 +++ Timeline/ClientApp/src/common/ImageCropper.tsx | 294 ++++++++++++++++ Timeline/ClientApp/src/common/LoadingPage.tsx | 12 + Timeline/ClientApp/src/common/OperationDialog.tsx | 380 +++++++++++++++++++++ Timeline/ClientApp/src/common/SearchInput.tsx | 63 ++++ Timeline/ClientApp/src/common/TimelineLogo.tsx | 26 ++ Timeline/ClientApp/src/common/UserTimelineLogo.tsx | 26 ++ Timeline/ClientApp/src/common/alert-service.ts | 59 ++++ Timeline/ClientApp/src/common/alert.scss | 20 ++ Timeline/ClientApp/src/common/common.scss | 38 +++ 13 files changed, 1246 insertions(+) create mode 100644 Timeline/ClientApp/src/common/AlertHost.tsx create mode 100644 Timeline/ClientApp/src/common/AppBar.tsx create mode 100644 Timeline/ClientApp/src/common/CollapseButton.tsx create mode 100644 Timeline/ClientApp/src/common/FileInput.tsx create mode 100644 Timeline/ClientApp/src/common/ImageCropper.tsx create mode 100644 Timeline/ClientApp/src/common/LoadingPage.tsx create mode 100644 Timeline/ClientApp/src/common/OperationDialog.tsx create mode 100644 Timeline/ClientApp/src/common/SearchInput.tsx create mode 100644 Timeline/ClientApp/src/common/TimelineLogo.tsx create mode 100644 Timeline/ClientApp/src/common/UserTimelineLogo.tsx create mode 100644 Timeline/ClientApp/src/common/alert-service.ts create mode 100644 Timeline/ClientApp/src/common/alert.scss create mode 100644 Timeline/ClientApp/src/common/common.scss (limited to 'Timeline/ClientApp/src/common') diff --git a/Timeline/ClientApp/src/common/AlertHost.tsx b/Timeline/ClientApp/src/common/AlertHost.tsx new file mode 100644 index 00000000..2d9dbb13 --- /dev/null +++ b/Timeline/ClientApp/src/common/AlertHost.tsx @@ -0,0 +1,75 @@ +import React, { useCallback } from 'react'; +import { Alert } from 'reactstrap'; +import without from 'lodash/without'; +import concat from 'lodash/concat'; + +import { + alertService, + AlertInfoEx, + kAlertHostId, + AlertInfo, +} from './alert-service'; + +interface AutoCloseAlertProps { + alert: AlertInfo; + close: () => void; +} + +export const AutoCloseAlert: React.FC = (props) => { + const { alert } = props; + + React.useEffect(() => { + const tag = window.setTimeout(props.close, 5000); + return () => window.clearTimeout(tag); + }, [props.close]); + + return ( + + {alert.message} + + ); +}; + +// oh what a bad name! +interface AlertInfoExEx extends AlertInfoEx { + close: () => void; +} + +export const AlertHost: React.FC = () => { + const [alerts, setAlerts] = React.useState([]); + + // react guarantee that state setters are stable, so we don't need to add it to dependency list + + const consume = useCallback((alert: AlertInfoEx): void => { + const alertEx: AlertInfoExEx = { + ...alert, + close: () => { + setAlerts((oldAlerts) => { + return without(oldAlerts, alertEx); + }); + }, + }; + setAlerts((oldAlerts) => { + return concat(oldAlerts, alertEx); + }); + }, []); + + React.useEffect(() => { + alertService.registerConsumer(consume); + return () => { + alertService.unregisterConsumer(consume); + }; + }, [consume]); + + return ( +
+ {alerts.map((alert) => { + return ( + + ); + })} +
+ ); +}; + +export default AlertHost; diff --git a/Timeline/ClientApp/src/common/AppBar.tsx b/Timeline/ClientApp/src/common/AppBar.tsx new file mode 100644 index 00000000..39794b0f --- /dev/null +++ b/Timeline/ClientApp/src/common/AppBar.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { useHistory, matchPath } from 'react-router'; +import { Link, NavLink } from 'react-router-dom'; +import { Navbar, NavbarToggler, Collapse, Nav, NavItem } from 'reactstrap'; +import { useMediaQuery } from 'react-responsive'; +import { useTranslation } from 'react-i18next'; + +import { useUser } from '../data/user'; +import { useOptionalVersionedAvatarUrl } from '../user/api'; + +import TimelineLogo from './TimelineLogo'; + +const AppBar: React.FC<{}> = (_) => { + const history = useHistory(); + const user = useUser(); + const avatarUrl = useOptionalVersionedAvatarUrl(user?._links?.avatar); + + const { t } = useTranslation(); + + const isUpMd = useMediaQuery({ + minWidth: getComputedStyle(document.documentElement).getPropertyValue( + '--breakpoint-md' + ), + }); + + const [isMenuOpen, setIsMenuOpen] = React.useState(false); + + const toggleMenu = React.useCallback((): void => { + setIsMenuOpen((oldIsMenuOpen) => !oldIsMenuOpen); + }, []); + + const isAdministrator = user && user.administrator; + + const rightArea = ( +
+ {user != null ? ( + + + + ) : ( + + {t('nav.login')} + + )} +
+ ); + + return ( + + + + Timeline + + + {isUpMd ? null : rightArea} + + + + + {isUpMd ? rightArea : null} + + + ); +}; + +export default AppBar; diff --git a/Timeline/ClientApp/src/common/CollapseButton.tsx b/Timeline/ClientApp/src/common/CollapseButton.tsx new file mode 100644 index 00000000..03f42e73 --- /dev/null +++ b/Timeline/ClientApp/src/common/CollapseButton.tsx @@ -0,0 +1,105 @@ +import React from 'react'; + +export interface CollapseButtonProps { + collapse: boolean; + toggle: (visibility: boolean) => void; + className?: string; +} + +const CollapseButton: React.FC = (props) => { + const { toggle, collapse, className } = props; + + const onClick = React.useCallback(() => { + toggle(!collapse); + }, [toggle, collapse]); + + return ( + + {(() => { + if (collapse) { + return ( + <> + + + + + + ); + } else { + return ( + <> + + + + + + ); + } + })()} + + ); +}; + +export default CollapseButton; diff --git a/Timeline/ClientApp/src/common/FileInput.tsx b/Timeline/ClientApp/src/common/FileInput.tsx new file mode 100644 index 00000000..d434f0fa --- /dev/null +++ b/Timeline/ClientApp/src/common/FileInput.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import clsx from 'clsx'; + +import { ExcludeKey } from '../type-utilities'; + +export interface FileInputProps + extends ExcludeKey< + React.InputHTMLAttributes, + 'type' | 'id' + > { + inputId?: string; + labelText: string; + color?: string; + className?: string; +} + +const FileInput: React.FC = props => { + const { inputId, labelText, color, className, ...otherProps } = props; + + const realInputId = React.useMemo(() => { + if (inputId != null) return inputId; + return ( + 'file-input-' + + (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1) + ); + }, [inputId]); + + return ( + <> + + + + ); +}; + +export default FileInput; diff --git a/Timeline/ClientApp/src/common/ImageCropper.tsx b/Timeline/ClientApp/src/common/ImageCropper.tsx new file mode 100644 index 00000000..3ef35b44 --- /dev/null +++ b/Timeline/ClientApp/src/common/ImageCropper.tsx @@ -0,0 +1,294 @@ +import * as React from 'react'; +import clsx from 'clsx'; + +export interface Clip { + left: number; + top: number; + width: number; +} + +interface NormailizedClip extends Clip { + height: number; +} + +interface ImageInfo { + width: number; + height: number; + landscape: boolean; + ratio: number; + maxClipWidth: number; + maxClipHeight: number; +} + +interface ImageCropperSavedState { + clip: NormailizedClip; + x: number; + y: number; + pointerId: number; +} + +export interface ImageCropperProps { + clip: Clip | null; + imageUrl: string; + onChange: (clip: Clip) => void; + imageElementCallback?: (element: HTMLImageElement | null) => void; + className?: string; +} + +const ImageCropper = (props: ImageCropperProps): React.ReactElement => { + const { clip, imageUrl, onChange, imageElementCallback, className } = props; + + const [oldState, setOldState] = React.useState( + null + ); + const [imageInfo, setImageInfo] = React.useState(null); + + const normalizeClip = (c: Clip | null | undefined): NormailizedClip => { + if (c == null) { + return { left: 0, top: 0, width: 0, height: 0 }; + } + + return { + left: c.left || 0, + top: c.top || 0, + width: c.width || 0, + height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0, + }; + }; + + const c = normalizeClip(clip); + + const imgElement = React.useRef(null); + + const onImageRef = React.useCallback( + (e: HTMLImageElement | null) => { + imgElement.current = e; + if (imageElementCallback != null && e == null) { + imageElementCallback(null); + } + }, + [imageElementCallback] + ); + + const onImageLoad = React.useCallback( + (e: React.SyntheticEvent) => { + const img = e.currentTarget; + const landscape = img.naturalWidth >= img.naturalHeight; + + const info = { + width: img.naturalWidth, + height: img.naturalHeight, + landscape, + ratio: img.naturalHeight / img.naturalWidth, + maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1, + maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight, + }; + setImageInfo(info); + onChange({ left: 0, top: 0, width: info.maxClipWidth }); + if (imageElementCallback != null) { + imageElementCallback(img); + } + }, + [onChange, imageElementCallback] + ); + + const onPointerDown = React.useCallback( + (e: React.PointerEvent) => { + if (oldState != null) return; + e.currentTarget.setPointerCapture(e.pointerId); + setOldState({ + x: e.clientX, + y: e.clientY, + clip: c, + pointerId: e.pointerId, + }); + }, + [oldState, c] + ); + + const onPointerUp = React.useCallback( + (e: React.PointerEvent) => { + if (oldState == null || oldState.pointerId !== e.pointerId) return; + e.currentTarget.releasePointerCapture(e.pointerId); + setOldState(null); + }, + [oldState] + ); + + const onPointerMove = React.useCallback( + (e: React.PointerEvent) => { + if (oldState == null) return; + + const oldClip = oldState.clip; + + const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; + + const moveRatio = { + x: movement.x / imgElement.current!.width, + y: movement.y / imgElement.current!.height, + }; + + const newRatio = { + x: oldClip.left + moveRatio.x, + y: oldClip.top + moveRatio.y, + }; + if (newRatio.x < 0) { + newRatio.x = 0; + } else if (newRatio.x > 1 - oldClip.width) { + newRatio.x = 1 - oldClip.width; + } + if (newRatio.y < 0) { + newRatio.y = 0; + } else if (newRatio.y > 1 - oldClip.height) { + newRatio.y = 1 - oldClip.height; + } + + onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width }); + }, + [oldState, onChange] + ); + + const onHandlerPointerMove = React.useCallback( + (e: React.PointerEvent) => { + if (oldState == null) return; + + const oldClip = oldState.clip; + + const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; + + const ratio = imageInfo == null ? 1 : imageInfo.ratio; + + const moveRatio = { + x: movement.x / imgElement.current!.width, + y: movement.x / imgElement.current!.width / ratio, + }; + + const newRatio = { + x: oldClip.width + moveRatio.x, + y: oldClip.height + moveRatio.y, + }; + + const maxRatio = { + x: Math.min(1 - oldClip.left, newRatio.x), + y: Math.min(1 - oldClip.top, newRatio.y), + }; + + const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio); + + let newWidth; + if (newRatio.x < 0) { + newWidth = 0; + } else if (newRatio.x > maxWidthRatio) { + newWidth = maxWidthRatio; + } else { + newWidth = newRatio.x; + } + + onChange({ left: oldClip.left, top: oldClip.top, width: newWidth }); + }, + [imageInfo, oldState, onChange] + ); + + // fuck!!! I just can't find a better way to implement this in pure css + const containerStyle: React.CSSProperties = (() => { + if (imageInfo == null) { + return { width: '100%', paddingTop: '100%', height: 0 }; + } else { + if (imageInfo.ratio > 1) { + return { + width: 100 / imageInfo.ratio + '%', + paddingTop: '100%', + height: 0, + }; + } else { + return { + width: '100%', + paddingTop: 100 * imageInfo.ratio + '%', + height: 0, + }; + } + } + })(); + + return ( +
+ to crop +
+
+
+
+
+ ); +}; + +export default ImageCropper; + +export function applyClipToImage( + image: HTMLImageElement, + clip: Clip, + mimeType: string +): Promise { + return new Promise((resolve, reject) => { + const naturalSize = { + width: image.naturalWidth, + height: image.naturalHeight, + }; + const clipArea = { + x: naturalSize.width * clip.left, + y: naturalSize.height * clip.top, + length: naturalSize.width * clip.width, + }; + + const canvas = document.createElement('canvas'); + canvas.width = clipArea.length; + canvas.height = clipArea.length; + const context = canvas.getContext('2d'); + + if (context == null) throw new Error('Failed to create context.'); + + context.drawImage( + image, + clipArea.x, + clipArea.y, + clipArea.length, + clipArea.length, + 0, + 0, + clipArea.length, + clipArea.length + ); + + canvas.toBlob((blob) => { + if (blob == null) { + reject(new Error('canvas.toBlob returns null')); + } else { + resolve(blob); + } + }, mimeType); + }); +} diff --git a/Timeline/ClientApp/src/common/LoadingPage.tsx b/Timeline/ClientApp/src/common/LoadingPage.tsx new file mode 100644 index 00000000..c1bc7105 --- /dev/null +++ b/Timeline/ClientApp/src/common/LoadingPage.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Spinner } from 'reactstrap'; + +const LoadingPage: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default LoadingPage; diff --git a/Timeline/ClientApp/src/common/OperationDialog.tsx b/Timeline/ClientApp/src/common/OperationDialog.tsx new file mode 100644 index 00000000..e7b6612c --- /dev/null +++ b/Timeline/ClientApp/src/common/OperationDialog.tsx @@ -0,0 +1,380 @@ +import React, { useState, InputHTMLAttributes } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Spinner, + Container, + ModalBody, + Label, + Input, + FormGroup, + FormFeedback, + ModalFooter, + Button, + Modal, + ModalHeader, + FormText +} from 'reactstrap'; + +const DefaultProcessPrompt: React.FC = _ => { + return ( + + + + ); +}; + +interface DefaultErrorPromptProps { + error?: string; +} + +const DefaultErrorPrompt: React.FC = props => { + const { t } = useTranslation(); + + let result =

{t('operationDialog.error')}

; + + if (props.error != null) { + result = ( + <> + {result} +

{props.error}

+ + ); + } + + return result; +}; + +export type OperationInputOptionalError = undefined | null | string; + +export interface OperationInputErrorInfo { + [index: number]: OperationInputOptionalError; +} + +export type OperationInputValidator = ( + value: TValue, + values: (string | boolean)[] +) => OperationInputOptionalError | OperationInputErrorInfo; + +export interface OperationTextInputInfo { + type: 'text'; + password?: boolean; + label?: string; + initValue?: string; + textFieldProps?: Omit< + React.InputHTMLAttributes, + 'type' | 'value' | 'onChange' + >; + helperText?: string; + validator?: OperationInputValidator; +} + +export interface OperationBoolInputInfo { + type: 'bool'; + label: string; + initValue?: boolean; +} + +export interface OperationSelectInputInfoOption { + value: string; + label: string; + icon?: React.ReactElement; +} + +export interface OperationSelectInputInfo { + type: 'select'; + label: string; + options: OperationSelectInputInfoOption[]; + initValue?: string; +} + +export type OperationInputInfo = + | OperationTextInputInfo + | OperationBoolInputInfo + | OperationSelectInputInfo; + +interface OperationResult { + type: 'success' | 'failure'; + data: unknown; +} + +interface OperationDialogProps { + open: boolean; + close: () => void; + title: React.ReactNode; + titleColor?: 'default' | 'dangerous' | 'create' | string; + onProcess: (inputs: (string | boolean)[]) => Promise; + inputScheme?: OperationInputInfo[]; + inputPrompt?: string | (() => React.ReactNode); + processPrompt?: () => React.ReactNode; + successPrompt?: (data: unknown) => React.ReactNode; + failurePrompt?: (error: unknown) => React.ReactNode; + onSuccessAndClose?: () => void; +} + +const OperationDialog: React.FC = props => { + const inputScheme = props.inputScheme ?? []; + + const { t } = useTranslation(); + + type Step = 'input' | 'process' | OperationResult; + const [step, setStep] = useState('input'); + const [values, setValues] = useState<(boolean | string)[]>( + inputScheme.map(i => { + if (i.type === 'bool') { + return i.initValue ?? false; + } else if (i.type === 'text' || i.type === 'select') { + return i.initValue ?? ''; + } else { + throw new Error('Unknown input scheme.'); + } + }) + ); + const [inputError, setInputError] = useState({}); + + const close = (): void => { + if (step !== 'process') { + props.close(); + if ( + typeof step === 'object' && + step.type === 'success' && + props.onSuccessAndClose + ) { + props.onSuccessAndClose(); + } + } else { + console.log('Attempt to close modal when processing.'); + } + }; + + const onConfirm = (): void => { + setStep('process'); + props.onProcess(values).then( + d => { + setStep({ + type: 'success', + data: d + }); + }, + e => { + setStep({ + type: 'failure', + data: e + }); + } + ); + }; + + let body: React.ReactNode; + if (step === 'input') { + let inputPrompt = + typeof props.inputPrompt === 'function' + ? props.inputPrompt() + : props.inputPrompt; + inputPrompt =
{inputPrompt}
; + + const updateValue = ( + index: number, + newValue: string | boolean + ): (string | boolean)[] => { + const oldValues = values; + const newValues = oldValues.slice(); + newValues[index] = newValue; + setValues(newValues); + return newValues; + }; + + const testErrorInfo = (errorInfo: OperationInputErrorInfo): boolean => { + for (let i = 0; i < inputScheme.length; i++) { + if (inputScheme[i].type === 'text' && errorInfo[i] != null) { + return true; + } + } + return false; + }; + + const calculateError = ( + oldError: OperationInputErrorInfo, + index: number, + newError: OperationInputOptionalError | OperationInputErrorInfo + ): OperationInputErrorInfo => { + if (newError === undefined) { + return oldError; + } else if (newError === null || typeof newError === 'string') { + return { ...oldError, [index]: newError }; + } else { + const newInputError: OperationInputErrorInfo = { ...oldError }; + for (const [index, error] of Object.entries(newError)) { + if (error !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (newInputError as any)[index] = error; + } + } + return newInputError; + } + }; + + const validateAll = (): boolean => { + let newInputError = inputError; + for (let i = 0; i < inputScheme.length; i++) { + const item = inputScheme[i]; + if (item.type === 'text') { + newInputError = calculateError( + newInputError, + i, + item.validator?.(values[i] as string, values) + ); + } + } + const result = !testErrorInfo(newInputError); + setInputError(newInputError); + return result; + }; + + body = ( + <> + + {inputPrompt} + {inputScheme.map((item, index) => { + const value = values[index]; + const error: string | undefined = (e => + typeof e === 'string' ? t(e) : undefined)(inputError?.[index]); + + if (item.type === 'text') { + return ( + + {item.label && } + { + const v = e.target.value; + const newValues = updateValue(index, v); + setInputError( + calculateError( + inputError, + index, + item.validator?.(v, newValues) + ) + ); + }} + invalid={error != null} + {...item.textFieldProps} + /> + {error != null && {error}} + {item.helperText && {t(item.helperText)}} + + ); + } else if (item.type === 'bool') { + return ( + + { + updateValue( + index, + (e.target as HTMLInputElement).checked + ); + }} + /> + + + ); + } else if (item.type === 'select') { + return ( + + + { + updateValue(index, event.target.value); + }} + > + {item.options.map((option, i) => { + return ( + + ); + })} + + + ); + } + })} + + + + + + + ); + } else if (step === 'process') { + body = ( + + {props.processPrompt?.() ?? } + + ); + } else { + let content: React.ReactNode; + const result = step; + if (result.type === 'success') { + content = + props.successPrompt?.(result.data) ?? t('operationDialog.success'); + if (typeof content === 'string') + content =

{content}

; + } else { + content = props.failurePrompt?.(result.data) ?? ; + if (typeof content === 'string') + content = ; + } + body = ( + <> + {content} + + + + + ); + } + + const title = typeof props.title === 'string' ? t(props.title) : props.title; + + return ( + + + {title} + + {body} + + ); +}; + +export default OperationDialog; diff --git a/Timeline/ClientApp/src/common/SearchInput.tsx b/Timeline/ClientApp/src/common/SearchInput.tsx new file mode 100644 index 00000000..46fb00d1 --- /dev/null +++ b/Timeline/ClientApp/src/common/SearchInput.tsx @@ -0,0 +1,63 @@ +import React, { useCallback } from 'react'; +import clsx from 'clsx'; +import { Spinner, Input, Button } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; + +export interface SearchInputProps { + value: string; + onChange: (value: string) => void; + onButtonClick: () => void; + className?: string; + loading?: boolean; + buttonText?: string; + placeholder?: string; + additionalButton?: React.ReactNode; +} + +const SearchInput: React.FC = (props) => { + const { onChange, onButtonClick } = props; + + const { t } = useTranslation(); + + const onInputChange = useCallback( + (event: React.ChangeEvent): void => { + onChange(event.currentTarget.value); + }, + [onChange] + ); + + const onInputKeyPress = useCallback( + (event: React.KeyboardEvent): void => { + if (event.key === 'Enter') { + onButtonClick(); + } + }, + [onButtonClick] + ); + + return ( +
+ +
+ {props.additionalButton} +
+
+ {props.loading ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default SearchInput; diff --git a/Timeline/ClientApp/src/common/TimelineLogo.tsx b/Timeline/ClientApp/src/common/TimelineLogo.tsx new file mode 100644 index 00000000..1ec62021 --- /dev/null +++ b/Timeline/ClientApp/src/common/TimelineLogo.tsx @@ -0,0 +1,26 @@ +import React, { SVGAttributes } from 'react'; + +export interface TimelineLogoProps extends SVGAttributes { + color?: string; +} + +const TimelineLogo: React.FC = props => { + const { color, ...forwardProps } = props; + const coercedColor = color ?? 'currentcolor'; + return ( + + + + + + ); +}; + +export default TimelineLogo; diff --git a/Timeline/ClientApp/src/common/UserTimelineLogo.tsx b/Timeline/ClientApp/src/common/UserTimelineLogo.tsx new file mode 100644 index 00000000..3b721985 --- /dev/null +++ b/Timeline/ClientApp/src/common/UserTimelineLogo.tsx @@ -0,0 +1,26 @@ +import React, { SVGAttributes } from 'react'; + +export interface UserTimelineLogoProps extends SVGAttributes { + color?: string; +} + +const UserTimelineLogo: React.FC = props => { + const { color, ...forwardProps } = props; + const coercedColor = color ?? 'currentcolor'; + + return ( + + + + + + + + + + + + ); +}; + +export default UserTimelineLogo; diff --git a/Timeline/ClientApp/src/common/alert-service.ts b/Timeline/ClientApp/src/common/alert-service.ts new file mode 100644 index 00000000..305752eb --- /dev/null +++ b/Timeline/ClientApp/src/common/alert-service.ts @@ -0,0 +1,59 @@ +import pull from 'lodash/pull'; + +export interface AlertInfo { + type?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info'; + message: string; +} + +export interface AlertInfoEx extends AlertInfo { + id: number; +} + +export type AlertConsumer = (alerts: AlertInfoEx) => void; + +export class AlertService { + private consumers: AlertConsumer[] = []; + private savedAlerts: AlertInfoEx[] = []; + private currentId = 1; + + private produce(alert: AlertInfoEx): void { + for (const consumer of this.consumers) { + consumer(alert); + } + } + + registerConsumer(consumer: AlertConsumer): void { + this.consumers.push(consumer); + if (this.savedAlerts.length !== 0) { + for (const alert of this.savedAlerts) { + this.produce(alert); + } + this.savedAlerts = []; + } + } + + unregisterConsumer(consumer: AlertConsumer): void { + pull(this.consumers, consumer); + } + + push(alert: AlertInfo): void { + const newAlert: AlertInfoEx = { ...alert, id: this.currentId++ }; + if (this.consumers.length === 0) { + this.savedAlerts.push(newAlert); + } else { + this.produce(newAlert); + } + } +} + +export const alertService = new AlertService(); + +export function pushAlert(alert: AlertInfo): void { + alertService.push(alert); +} + +export const kAlertHostId = 'alert-host'; + +export function getAlertHost(): HTMLElement | null { + return document.getElementById(kAlertHostId); +} diff --git a/Timeline/ClientApp/src/common/alert.scss b/Timeline/ClientApp/src/common/alert.scss new file mode 100644 index 00000000..09877727 --- /dev/null +++ b/Timeline/ClientApp/src/common/alert.scss @@ -0,0 +1,20 @@ +.alert-container { + position: fixed; + z-index: $zindex-popover; +} + +@include media-breakpoint-up(sm) { + .alert-container { + bottom: 0; + right: 0; + } +} + +@include media-breakpoint-down(sm) { + .alert-container { + bottom: 0; + right: 0; + left: 0; + text-align: center; + } +} diff --git a/Timeline/ClientApp/src/common/common.scss b/Timeline/ClientApp/src/common/common.scss new file mode 100644 index 00000000..5998c086 --- /dev/null +++ b/Timeline/ClientApp/src/common/common.scss @@ -0,0 +1,38 @@ +.image-cropper-container { + position: relative; + box-sizing: border-box; + user-select: none; +} + +.image-cropper-container img { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; +} + +.image-cropper-mask-container { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + overflow: hidden; +} + +.image-cropper-mask { + position: absolute; + box-shadow: 0 0 0 10000px rgba(255, 255, 255, 80%); + touch-action: none; +} + +.image-cropper-handler { + position: absolute; + width: 26px; + height: 26px; + border: black solid 2px; + border-radius: 50%; + background: white; + touch-action: none; +} -- cgit v1.2.3