aboutsummaryrefslogtreecommitdiff
path: root/Timeline/ClientApp/src/app/common
diff options
context:
space:
mode:
Diffstat (limited to 'Timeline/ClientApp/src/app/common')
-rw-r--r--Timeline/ClientApp/src/app/common/AlertHost.tsx192
-rw-r--r--Timeline/ClientApp/src/app/common/AppBar.tsx214
-rw-r--r--Timeline/ClientApp/src/app/common/BlobImage.tsx58
-rw-r--r--Timeline/ClientApp/src/app/common/FileInput.tsx82
-rw-r--r--Timeline/ClientApp/src/app/common/ImageCropper.tsx612
-rw-r--r--Timeline/ClientApp/src/app/common/LoadingPage.tsx24
-rw-r--r--Timeline/ClientApp/src/app/common/OperationDialog.tsx762
-rw-r--r--Timeline/ClientApp/src/app/common/SearchInput.tsx126
-rw-r--r--Timeline/ClientApp/src/app/common/TimelineLogo.tsx52
-rw-r--r--Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx52
-rw-r--r--Timeline/ClientApp/src/app/common/alert-service.ts122
-rw-r--r--Timeline/ClientApp/src/app/common/alert.sass30
-rw-r--r--Timeline/ClientApp/src/app/common/common.sass66
13 files changed, 1196 insertions, 1196 deletions
diff --git a/Timeline/ClientApp/src/app/common/AlertHost.tsx b/Timeline/ClientApp/src/app/common/AlertHost.tsx
index 89831073..e22354fa 100644
--- a/Timeline/ClientApp/src/app/common/AlertHost.tsx
+++ b/Timeline/ClientApp/src/app/common/AlertHost.tsx
@@ -1,96 +1,96 @@
-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';
-import { useTranslation } from 'react-i18next';
-
-interface AutoCloseAlertProps {
- alert: AlertInfo;
- close: () => void;
-}
-
-export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
- const { alert } = props;
- const { dismissTime } = alert;
-
- const { t } = useTranslation();
-
- React.useEffect(() => {
- const tag =
- dismissTime === 'never'
- ? null
- : typeof dismissTime === 'number'
- ? window.setTimeout(props.close, dismissTime)
- : window.setTimeout(props.close, 5000);
- return () => {
- if (tag != null) {
- window.clearTimeout(tag);
- }
- };
- }, [dismissTime, props.close]);
-
- return (
- <Alert className="m-3" color={alert.type ?? 'primary'} toggle={props.close}>
- {(() => {
- const { message } = alert;
- if (typeof message === 'function') {
- const Message = message;
- return <Message />;
- } else if (typeof message === 'object' && message.type === 'i18n') {
- return t(message.key);
- } else return alert.message;
- })()}
- </Alert>
- );
-};
-
-// oh what a bad name!
-interface AlertInfoExEx extends AlertInfoEx {
- close: () => void;
-}
-
-export const AlertHost: React.FC = () => {
- const [alerts, setAlerts] = React.useState<AlertInfoExEx[]>([]);
-
- // 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 (
- <div id={kAlertHostId} className="alert-container">
- {alerts.map((alert) => {
- return (
- <AutoCloseAlert key={alert.id} alert={alert} close={alert.close} />
- );
- })}
- </div>
- );
-};
-
-export default AlertHost;
+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";
+import { useTranslation } from "react-i18next";
+
+interface AutoCloseAlertProps {
+ alert: AlertInfo;
+ close: () => void;
+}
+
+export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
+ const { alert } = props;
+ const { dismissTime } = alert;
+
+ const { t } = useTranslation();
+
+ React.useEffect(() => {
+ const tag =
+ dismissTime === "never"
+ ? null
+ : typeof dismissTime === "number"
+ ? window.setTimeout(props.close, dismissTime)
+ : window.setTimeout(props.close, 5000);
+ return () => {
+ if (tag != null) {
+ window.clearTimeout(tag);
+ }
+ };
+ }, [dismissTime, props.close]);
+
+ return (
+ <Alert className="m-3" color={alert.type ?? "primary"} toggle={props.close}>
+ {(() => {
+ const { message } = alert;
+ if (typeof message === "function") {
+ const Message = message;
+ return <Message />;
+ } else if (typeof message === "object" && message.type === "i18n") {
+ return t(message.key);
+ } else return alert.message;
+ })()}
+ </Alert>
+ );
+};
+
+// oh what a bad name!
+interface AlertInfoExEx extends AlertInfoEx {
+ close: () => void;
+}
+
+export const AlertHost: React.FC = () => {
+ const [alerts, setAlerts] = React.useState<AlertInfoExEx[]>([]);
+
+ // 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 (
+ <div id={kAlertHostId} className="alert-container">
+ {alerts.map((alert) => {
+ return (
+ <AutoCloseAlert key={alert.id} alert={alert} close={alert.close} />
+ );
+ })}
+ </div>
+ );
+};
+
+export default AlertHost;
diff --git a/Timeline/ClientApp/src/app/common/AppBar.tsx b/Timeline/ClientApp/src/app/common/AppBar.tsx
index 8349aef7..59239696 100644
--- a/Timeline/ClientApp/src/app/common/AppBar.tsx
+++ b/Timeline/ClientApp/src/app/common/AppBar.tsx
@@ -1,107 +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, useAvatar } from '../data/user';
-
-import TimelineLogo from './TimelineLogo';
-import BlobImage from './BlobImage';
-
-const AppBar: React.FC = (_) => {
- const history = useHistory();
- const user = useUser();
- const avatar = useAvatar(user?.username);
-
- 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 = (
- <div className="ml-auto mr-2">
- {user != null ? (
- <NavLink to={`/users/${user.username}`}>
- <BlobImage
- className="avatar small rounded-circle bg-white"
- blob={avatar}
- />
- </NavLink>
- ) : (
- <NavLink className="text-light" to="/login">
- {t('nav.login')}
- </NavLink>
- )}
- </div>
- );
-
- return (
- <Navbar dark className="fixed-top w-100 bg-primary app-bar" expand="md">
- <Link to="/" className="navbar-brand d-flex align-items-center">
- <TimelineLogo style={{ height: '1em' }} />
- Timeline
- </Link>
-
- {isUpMd ? null : rightArea}
-
- <NavbarToggler onClick={toggleMenu} />
- <Collapse isOpen={isMenuOpen} navbar>
- <Nav className="mr-auto" navbar>
- <NavItem
- className={
- matchPath(history.location.pathname, '/settings')
- ? 'active'
- : undefined
- }
- >
- <NavLink className="nav-link" to="/settings">
- {t('nav.settings')}
- </NavLink>
- </NavItem>
-
- <NavItem
- className={
- matchPath(history.location.pathname, '/about')
- ? 'active'
- : undefined
- }
- >
- <NavLink className="nav-link" to="/about">
- {t('nav.about')}
- </NavLink>
- </NavItem>
-
- {isAdministrator && (
- <NavItem
- className={
- matchPath(history.location.pathname, '/admin')
- ? 'active'
- : undefined
- }
- >
- <NavLink className="nav-link" to="/admin">
- Administration
- </NavLink>
- </NavItem>
- )}
- </Nav>
- {isUpMd ? rightArea : null}
- </Collapse>
- </Navbar>
- );
-};
-
-export default AppBar;
+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, useAvatar } from "../data/user";
+
+import TimelineLogo from "./TimelineLogo";
+import BlobImage from "./BlobImage";
+
+const AppBar: React.FC = (_) => {
+ const history = useHistory();
+ const user = useUser();
+ const avatar = useAvatar(user?.username);
+
+ 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 = (
+ <div className="ml-auto mr-2">
+ {user != null ? (
+ <NavLink to={`/users/${user.username}`}>
+ <BlobImage
+ className="avatar small rounded-circle bg-white"
+ blob={avatar}
+ />
+ </NavLink>
+ ) : (
+ <NavLink className="text-light" to="/login">
+ {t("nav.login")}
+ </NavLink>
+ )}
+ </div>
+ );
+
+ return (
+ <Navbar dark className="fixed-top w-100 bg-primary app-bar" expand="md">
+ <Link to="/" className="navbar-brand d-flex align-items-center">
+ <TimelineLogo style={{ height: "1em" }} />
+ Timeline
+ </Link>
+
+ {isUpMd ? null : rightArea}
+
+ <NavbarToggler onClick={toggleMenu} />
+ <Collapse isOpen={isMenuOpen} navbar>
+ <Nav className="mr-auto" navbar>
+ <NavItem
+ className={
+ matchPath(history.location.pathname, "/settings")
+ ? "active"
+ : undefined
+ }
+ >
+ <NavLink className="nav-link" to="/settings">
+ {t("nav.settings")}
+ </NavLink>
+ </NavItem>
+
+ <NavItem
+ className={
+ matchPath(history.location.pathname, "/about")
+ ? "active"
+ : undefined
+ }
+ >
+ <NavLink className="nav-link" to="/about">
+ {t("nav.about")}
+ </NavLink>
+ </NavItem>
+
+ {isAdministrator && (
+ <NavItem
+ className={
+ matchPath(history.location.pathname, "/admin")
+ ? "active"
+ : undefined
+ }
+ >
+ <NavLink className="nav-link" to="/admin">
+ Administration
+ </NavLink>
+ </NavItem>
+ )}
+ </Nav>
+ {isUpMd ? rightArea : null}
+ </Collapse>
+ </Navbar>
+ );
+};
+
+export default AppBar;
diff --git a/Timeline/ClientApp/src/app/common/BlobImage.tsx b/Timeline/ClientApp/src/app/common/BlobImage.tsx
index 7e5e2447..8602f550 100644
--- a/Timeline/ClientApp/src/app/common/BlobImage.tsx
+++ b/Timeline/ClientApp/src/app/common/BlobImage.tsx
@@ -1,29 +1,29 @@
-import React from 'react';
-
-import { ExcludeKey } from '../utilities/type';
-
-const BlobImage: React.FC<
- ExcludeKey<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {
- blob?: Blob | unknown;
- }
-> = (props) => {
- const { blob, ...otherProps } = props;
-
- const [url, setUrl] = React.useState<string | undefined>(undefined);
-
- React.useEffect(() => {
- if (blob instanceof Blob) {
- const url = URL.createObjectURL(blob);
- setUrl(url);
- return () => {
- URL.revokeObjectURL(url);
- };
- } else {
- setUrl(undefined);
- }
- }, [blob]);
-
- return <img {...otherProps} src={url} />;
-};
-
-export default BlobImage;
+import React from "react";
+
+import { ExcludeKey } from "../utilities/type";
+
+const BlobImage: React.FC<
+ ExcludeKey<React.ImgHTMLAttributes<HTMLImageElement>, "src"> & {
+ blob?: Blob | unknown;
+ }
+> = (props) => {
+ const { blob, ...otherProps } = props;
+
+ const [url, setUrl] = React.useState<string | undefined>(undefined);
+
+ React.useEffect(() => {
+ if (blob instanceof Blob) {
+ const url = URL.createObjectURL(blob);
+ setUrl(url);
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setUrl(undefined);
+ }
+ }, [blob]);
+
+ return <img {...otherProps} src={url} />;
+};
+
+export default BlobImage;
diff --git a/Timeline/ClientApp/src/app/common/FileInput.tsx b/Timeline/ClientApp/src/app/common/FileInput.tsx
index 94f4aa43..3d1bc2b3 100644
--- a/Timeline/ClientApp/src/app/common/FileInput.tsx
+++ b/Timeline/ClientApp/src/app/common/FileInput.tsx
@@ -1,41 +1,41 @@
-import React from 'react';
-import clsx from 'clsx';
-
-import { ExcludeKey } from '../utilities/type';
-
-export interface FileInputProps
- extends ExcludeKey<
- React.InputHTMLAttributes<HTMLInputElement>,
- 'type' | 'id'
- > {
- inputId?: string;
- labelText: string;
- color?: string;
- className?: string;
-}
-
-const FileInput: React.FC<FileInputProps> = props => {
- const { inputId, labelText, color, className, ...otherProps } = props;
-
- const realInputId = React.useMemo<string>(() => {
- if (inputId != null) return inputId;
- return (
- 'file-input-' +
- (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1)
- );
- }, [inputId]);
-
- return (
- <>
- <input className="d-none" type="file" id={realInputId} {...otherProps} />
- <label
- htmlFor={realInputId}
- className={clsx('btn', 'btn-' + (color ?? 'primary'), className)}
- >
- {labelText}
- </label>
- </>
- );
-};
-
-export default FileInput;
+import React from "react";
+import clsx from "clsx";
+
+import { ExcludeKey } from "../utilities/type";
+
+export interface FileInputProps
+ extends ExcludeKey<
+ React.InputHTMLAttributes<HTMLInputElement>,
+ "type" | "id"
+ > {
+ inputId?: string;
+ labelText: string;
+ color?: string;
+ className?: string;
+}
+
+const FileInput: React.FC<FileInputProps> = (props) => {
+ const { inputId, labelText, color, className, ...otherProps } = props;
+
+ const realInputId = React.useMemo<string>(() => {
+ if (inputId != null) return inputId;
+ return (
+ "file-input-" +
+ (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1)
+ );
+ }, [inputId]);
+
+ return (
+ <>
+ <input className="d-none" type="file" id={realInputId} {...otherProps} />
+ <label
+ htmlFor={realInputId}
+ className={clsx("btn", "btn-" + (color ?? "primary"), className)}
+ >
+ {labelText}
+ </label>
+ </>
+ );
+};
+
+export default FileInput;
diff --git a/Timeline/ClientApp/src/app/common/ImageCropper.tsx b/Timeline/ClientApp/src/app/common/ImageCropper.tsx
index fb9bb4f9..cd510969 100644
--- a/Timeline/ClientApp/src/app/common/ImageCropper.tsx
+++ b/Timeline/ClientApp/src/app/common/ImageCropper.tsx
@@ -1,306 +1,306 @@
-import * as React from 'react';
-import clsx from 'clsx';
-
-import { UiLogicError } from '../common';
-
-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<ImageCropperSavedState | null>(
- null
- );
- const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(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 imgElementRef = React.useRef<HTMLImageElement | null>(null);
-
- const onImageRef = React.useCallback(
- (e: HTMLImageElement | null) => {
- imgElementRef.current = e;
- if (imageElementCallback != null && e == null) {
- imageElementCallback(null);
- }
- },
- [imageElementCallback]
- );
-
- const onImageLoad = React.useCallback(
- (e: React.SyntheticEvent<HTMLImageElement>) => {
- 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 { current: imgElement } = imgElementRef;
-
- if (imgElement == null) throw new UiLogicError('Image element is null.');
-
- const moveRatio = {
- x: movement.x / imgElement.width,
- y: movement.y / imgElement.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 { current: imgElement } = imgElementRef;
-
- if (imgElement == null) throw new UiLogicError('Image element is null.');
-
- const moveRatio = {
- x: movement.x / imgElement.width,
- y: movement.x / imgElement.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]
- );
-
- const toPercentage = (n: number): string => `${n}%`;
-
- // 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: toPercentage(100 / imageInfo.ratio),
- paddingTop: '100%',
- height: 0,
- };
- } else {
- return {
- width: '100%',
- paddingTop: toPercentage(100 * imageInfo.ratio),
- height: 0,
- };
- }
- }
- })();
-
- return (
- <div
- className={clsx('image-cropper-container', className)}
- style={containerStyle}
- >
- <img ref={onImageRef} src={imageUrl} onLoad={onImageLoad} alt="to crop" />
- <div className="image-cropper-mask-container">
- <div
- className="image-cropper-mask"
- touch-action="none"
- style={{
- left: toPercentage(c.left * 100),
- top: toPercentage(c.top * 100),
- width: toPercentage(c.width * 100),
- height: toPercentage(c.height * 100),
- }}
- onPointerMove={onPointerMove}
- onPointerDown={onPointerDown}
- onPointerUp={onPointerUp}
- />
- </div>
- <div
- className="image-cropper-handler"
- touch-action="none"
- style={{
- left: `calc(${(c.left + c.width) * 100}% - 15px)`,
- top: `calc(${(c.top + c.height) * 100}% - 15px)`,
- }}
- onPointerMove={onHandlerPointerMove}
- onPointerDown={onPointerDown}
- onPointerUp={onPointerUp}
- />
- </div>
- );
-};
-
-export default ImageCropper;
-
-export function applyClipToImage(
- image: HTMLImageElement,
- clip: Clip,
- mimeType: string
-): Promise<Blob> {
- 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);
- });
-}
+import * as React from "react";
+import clsx from "clsx";
+
+import { UiLogicError } from "../common";
+
+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<ImageCropperSavedState | null>(
+ null
+ );
+ const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(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 imgElementRef = React.useRef<HTMLImageElement | null>(null);
+
+ const onImageRef = React.useCallback(
+ (e: HTMLImageElement | null) => {
+ imgElementRef.current = e;
+ if (imageElementCallback != null && e == null) {
+ imageElementCallback(null);
+ }
+ },
+ [imageElementCallback]
+ );
+
+ const onImageLoad = React.useCallback(
+ (e: React.SyntheticEvent<HTMLImageElement>) => {
+ 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 { current: imgElement } = imgElementRef;
+
+ if (imgElement == null) throw new UiLogicError("Image element is null.");
+
+ const moveRatio = {
+ x: movement.x / imgElement.width,
+ y: movement.y / imgElement.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 { current: imgElement } = imgElementRef;
+
+ if (imgElement == null) throw new UiLogicError("Image element is null.");
+
+ const moveRatio = {
+ x: movement.x / imgElement.width,
+ y: movement.x / imgElement.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]
+ );
+
+ const toPercentage = (n: number): string => `${n}%`;
+
+ // 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: toPercentage(100 / imageInfo.ratio),
+ paddingTop: "100%",
+ height: 0,
+ };
+ } else {
+ return {
+ width: "100%",
+ paddingTop: toPercentage(100 * imageInfo.ratio),
+ height: 0,
+ };
+ }
+ }
+ })();
+
+ return (
+ <div
+ className={clsx("image-cropper-container", className)}
+ style={containerStyle}
+ >
+ <img ref={onImageRef} src={imageUrl} onLoad={onImageLoad} alt="to crop" />
+ <div className="image-cropper-mask-container">
+ <div
+ className="image-cropper-mask"
+ touch-action="none"
+ style={{
+ left: toPercentage(c.left * 100),
+ top: toPercentage(c.top * 100),
+ width: toPercentage(c.width * 100),
+ height: toPercentage(c.height * 100),
+ }}
+ onPointerMove={onPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ <div
+ className="image-cropper-handler"
+ touch-action="none"
+ style={{
+ left: `calc(${(c.left + c.width) * 100}% - 15px)`,
+ top: `calc(${(c.top + c.height) * 100}% - 15px)`,
+ }}
+ onPointerMove={onHandlerPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ );
+};
+
+export default ImageCropper;
+
+export function applyClipToImage(
+ image: HTMLImageElement,
+ clip: Clip,
+ mimeType: string
+): Promise<Blob> {
+ 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/app/common/LoadingPage.tsx b/Timeline/ClientApp/src/app/common/LoadingPage.tsx
index c1bc7105..a849126d 100644
--- a/Timeline/ClientApp/src/app/common/LoadingPage.tsx
+++ b/Timeline/ClientApp/src/app/common/LoadingPage.tsx
@@ -1,12 +1,12 @@
-import React from 'react';
-import { Spinner } from 'reactstrap';
-
-const LoadingPage: React.FC = () => {
- return (
- <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center">
- <Spinner style={{ height: '2.5rem', width: '2.5rem' }} color="primary" />
- </div>
- );
-};
-
-export default LoadingPage;
+import React from "react";
+import { Spinner } from "reactstrap";
+
+const LoadingPage: React.FC = () => {
+ return (
+ <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center">
+ <Spinner style={{ height: "2.5rem", width: "2.5rem" }} color="primary" />
+ </div>
+ );
+};
+
+export default LoadingPage;
diff --git a/Timeline/ClientApp/src/app/common/OperationDialog.tsx b/Timeline/ClientApp/src/app/common/OperationDialog.tsx
index 501a353e..bca4580c 100644
--- a/Timeline/ClientApp/src/app/common/OperationDialog.tsx
+++ b/Timeline/ClientApp/src/app/common/OperationDialog.tsx
@@ -1,381 +1,381 @@
-import React, { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import {
- Spinner,
- Container,
- ModalBody,
- Label,
- Input,
- FormGroup,
- FormFeedback,
- ModalFooter,
- Button,
- Modal,
- ModalHeader,
- FormText,
-} from 'reactstrap';
-
-import { UiLogicError } from '../common';
-
-const DefaultProcessPrompt: React.FC = (_) => {
- return (
- <Container className="justify-content-center align-items-center">
- <Spinner />
- </Container>
- );
-};
-
-interface DefaultErrorPromptProps {
- error?: string;
-}
-
-const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => {
- const { t } = useTranslation();
-
- let result = <p className="text-danger">{t('operationDialog.error')}</p>;
-
- if (props.error != null) {
- result = (
- <>
- {result}
- <p className="text-danger">{props.error}</p>
- </>
- );
- }
-
- return result;
-};
-
-export type OperationInputOptionalError = undefined | null | string;
-
-export interface OperationInputErrorInfo {
- [index: number]: OperationInputOptionalError;
-}
-
-export type OperationInputValidator<TValue> = (
- value: TValue,
- values: (string | boolean)[]
-) => OperationInputOptionalError | OperationInputErrorInfo;
-
-export interface OperationTextInputInfo {
- type: 'text';
- password?: boolean;
- label?: string;
- initValue?: string;
- textFieldProps?: Omit<
- React.InputHTMLAttributes<HTMLInputElement>,
- 'type' | 'value' | 'onChange'
- >;
- helperText?: string;
- validator?: OperationInputValidator<string>;
-}
-
-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<unknown>;
- 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<OperationDialogProps> = (props) => {
- const inputScheme = props.inputScheme ?? [];
-
- const { t } = useTranslation();
-
- type Step = 'input' | 'process' | OperationResult;
- const [step, setStep] = useState<Step>('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 UiLogicError('Unknown input scheme.');
- }
- })
- );
- const [inputError, setInputError] = useState<OperationInputErrorInfo>({});
-
- 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: unknown) => {
- setStep({
- type: 'success',
- data: d,
- });
- },
- (e: unknown) => {
- setStep({
- type: 'failure',
- data: e,
- });
- }
- );
- };
-
- let body: React.ReactNode;
- if (step === 'input') {
- let inputPrompt =
- typeof props.inputPrompt === 'function'
- ? props.inputPrompt()
- : props.inputPrompt;
- inputPrompt = <h6>{inputPrompt}</h6>;
-
- 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) {
- newInputError[+index] = error as OperationInputOptionalError;
- }
- }
- 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 = (
- <>
- <ModalBody>
- {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 (
- <FormGroup key={index}>
- {item.label && <Label>{t(item.label)}</Label>}
- <Input
- type={item.password === true ? 'password' : 'text'}
- value={value as string}
- onChange={(e) => {
- 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 && <FormFeedback>{error}</FormFeedback>}
- {item.helperText && <FormText>{t(item.helperText)}</FormText>}
- </FormGroup>
- );
- } else if (item.type === 'bool') {
- return (
- <FormGroup check key={index}>
- <Input
- type="checkbox"
- value={value as string}
- onChange={(e) => {
- updateValue(
- index,
- (e.target as HTMLInputElement).checked
- );
- }}
- />
- <Label check>{t(item.label)}</Label>
- </FormGroup>
- );
- } else if (item.type === 'select') {
- return (
- <FormGroup key={index}>
- <Label>{t(item.label)}</Label>
- <Input
- type="select"
- value={value as string}
- onChange={(event) => {
- updateValue(index, event.target.value);
- }}
- >
- {item.options.map((option, i) => {
- return (
- <option value={option.value} key={i}>
- {option.icon}
- {t(option.label)}
- </option>
- );
- })}
- </Input>
- </FormGroup>
- );
- }
- })}
- </ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={close}>
- {t('operationDialog.cancel')}
- </Button>
- <Button
- color="primary"
- disabled={testErrorInfo(inputError)}
- onClick={() => {
- if (validateAll()) {
- onConfirm();
- }
- }}
- >
- {t('operationDialog.confirm')}
- </Button>
- </ModalFooter>
- </>
- );
- } else if (step === 'process') {
- body = (
- <ModalBody>
- {props.processPrompt?.() ?? <DefaultProcessPrompt />}
- </ModalBody>
- );
- } 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 = <p className="text-success">{content}</p>;
- } else {
- content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
- if (typeof content === 'string')
- content = <DefaultErrorPrompt error={content} />;
- }
- body = (
- <>
- <ModalBody>{content}</ModalBody>
- <ModalFooter>
- <Button color="primary" onClick={close}>
- {t('operationDialog.ok')}
- </Button>
- </ModalFooter>
- </>
- );
- }
-
- const title = typeof props.title === 'string' ? t(props.title) : props.title;
-
- return (
- <Modal isOpen={props.open} toggle={close}>
- <ModalHeader
- className={
- props.titleColor != null
- ? 'text-' +
- (props.titleColor === 'create'
- ? 'success'
- : props.titleColor === 'dangerous'
- ? 'danger'
- : props.titleColor)
- : undefined
- }
- >
- {title}
- </ModalHeader>
- {body}
- </Modal>
- );
-};
-
-export default OperationDialog;
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Spinner,
+ Container,
+ ModalBody,
+ Label,
+ Input,
+ FormGroup,
+ FormFeedback,
+ ModalFooter,
+ Button,
+ Modal,
+ ModalHeader,
+ FormText,
+} from "reactstrap";
+
+import { UiLogicError } from "../common";
+
+const DefaultProcessPrompt: React.FC = (_) => {
+ return (
+ <Container className="justify-content-center align-items-center">
+ <Spinner />
+ </Container>
+ );
+};
+
+interface DefaultErrorPromptProps {
+ error?: string;
+}
+
+const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => {
+ const { t } = useTranslation();
+
+ let result = <p className="text-danger">{t("operationDialog.error")}</p>;
+
+ if (props.error != null) {
+ result = (
+ <>
+ {result}
+ <p className="text-danger">{props.error}</p>
+ </>
+ );
+ }
+
+ return result;
+};
+
+export type OperationInputOptionalError = undefined | null | string;
+
+export interface OperationInputErrorInfo {
+ [index: number]: OperationInputOptionalError;
+}
+
+export type OperationInputValidator<TValue> = (
+ value: TValue,
+ values: (string | boolean)[]
+) => OperationInputOptionalError | OperationInputErrorInfo;
+
+export interface OperationTextInputInfo {
+ type: "text";
+ password?: boolean;
+ label?: string;
+ initValue?: string;
+ textFieldProps?: Omit<
+ React.InputHTMLAttributes<HTMLInputElement>,
+ "type" | "value" | "onChange"
+ >;
+ helperText?: string;
+ validator?: OperationInputValidator<string>;
+}
+
+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<unknown>;
+ 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<OperationDialogProps> = (props) => {
+ const inputScheme = props.inputScheme ?? [];
+
+ const { t } = useTranslation();
+
+ type Step = "input" | "process" | OperationResult;
+ const [step, setStep] = useState<Step>("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 UiLogicError("Unknown input scheme.");
+ }
+ })
+ );
+ const [inputError, setInputError] = useState<OperationInputErrorInfo>({});
+
+ 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: unknown) => {
+ setStep({
+ type: "success",
+ data: d,
+ });
+ },
+ (e: unknown) => {
+ setStep({
+ type: "failure",
+ data: e,
+ });
+ }
+ );
+ };
+
+ let body: React.ReactNode;
+ if (step === "input") {
+ let inputPrompt =
+ typeof props.inputPrompt === "function"
+ ? props.inputPrompt()
+ : props.inputPrompt;
+ inputPrompt = <h6>{inputPrompt}</h6>;
+
+ 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) {
+ newInputError[+index] = error as OperationInputOptionalError;
+ }
+ }
+ 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 = (
+ <>
+ <ModalBody>
+ {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 (
+ <FormGroup key={index}>
+ {item.label && <Label>{t(item.label)}</Label>}
+ <Input
+ type={item.password === true ? "password" : "text"}
+ value={value as string}
+ onChange={(e) => {
+ 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 && <FormFeedback>{error}</FormFeedback>}
+ {item.helperText && <FormText>{t(item.helperText)}</FormText>}
+ </FormGroup>
+ );
+ } else if (item.type === "bool") {
+ return (
+ <FormGroup check key={index}>
+ <Input
+ type="checkbox"
+ value={value as string}
+ onChange={(e) => {
+ updateValue(
+ index,
+ (e.target as HTMLInputElement).checked
+ );
+ }}
+ />
+ <Label check>{t(item.label)}</Label>
+ </FormGroup>
+ );
+ } else if (item.type === "select") {
+ return (
+ <FormGroup key={index}>
+ <Label>{t(item.label)}</Label>
+ <Input
+ type="select"
+ value={value as string}
+ onChange={(event) => {
+ updateValue(index, event.target.value);
+ }}
+ >
+ {item.options.map((option, i) => {
+ return (
+ <option value={option.value} key={i}>
+ {option.icon}
+ {t(option.label)}
+ </option>
+ );
+ })}
+ </Input>
+ </FormGroup>
+ );
+ }
+ })}
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={close}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button
+ color="primary"
+ disabled={testErrorInfo(inputError)}
+ onClick={() => {
+ if (validateAll()) {
+ onConfirm();
+ }
+ }}
+ >
+ {t("operationDialog.confirm")}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (step === "process") {
+ body = (
+ <ModalBody>
+ {props.processPrompt?.() ?? <DefaultProcessPrompt />}
+ </ModalBody>
+ );
+ } 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 = <p className="text-success">{content}</p>;
+ } else {
+ content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
+ if (typeof content === "string")
+ content = <DefaultErrorPrompt error={content} />;
+ }
+ body = (
+ <>
+ <ModalBody>{content}</ModalBody>
+ <ModalFooter>
+ <Button color="primary" onClick={close}>
+ {t("operationDialog.ok")}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ }
+
+ const title = typeof props.title === "string" ? t(props.title) : props.title;
+
+ return (
+ <Modal isOpen={props.open} toggle={close}>
+ <ModalHeader
+ className={
+ props.titleColor != null
+ ? "text-" +
+ (props.titleColor === "create"
+ ? "success"
+ : props.titleColor === "dangerous"
+ ? "danger"
+ : props.titleColor)
+ : undefined
+ }
+ >
+ {title}
+ </ModalHeader>
+ {body}
+ </Modal>
+ );
+};
+
+export default OperationDialog;
diff --git a/Timeline/ClientApp/src/app/common/SearchInput.tsx b/Timeline/ClientApp/src/app/common/SearchInput.tsx
index 46fb00d1..5a0b0eaa 100644
--- a/Timeline/ClientApp/src/app/common/SearchInput.tsx
+++ b/Timeline/ClientApp/src/app/common/SearchInput.tsx
@@ -1,63 +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<SearchInputProps> = (props) => {
- const { onChange, onButtonClick } = props;
-
- const { t } = useTranslation();
-
- const onInputChange = useCallback(
- (event: React.ChangeEvent<HTMLInputElement>): void => {
- onChange(event.currentTarget.value);
- },
- [onChange]
- );
-
- const onInputKeyPress = useCallback(
- (event: React.KeyboardEvent<HTMLInputElement>): void => {
- if (event.key === 'Enter') {
- onButtonClick();
- }
- },
- [onButtonClick]
- );
-
- return (
- <div className={clsx('form-inline my-2', props.className)}>
- <Input
- className="mr-sm-2"
- value={props.value}
- onChange={onInputChange}
- onKeyPress={onInputKeyPress}
- placeholder={props.placeholder}
- />
- <div className="mt-2 mt-sm-0 order-sm-last ml-sm-3">
- {props.additionalButton}
- </div>
- <div className="mt-2 mt-sm-0 ml-auto ml-sm-0">
- {props.loading ? (
- <Spinner />
- ) : (
- <Button outline color="primary" onClick={props.onButtonClick}>
- {props.buttonText ?? t('search')}
- </Button>
- )}
- </div>
- </div>
- );
-};
-
-export default SearchInput;
+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<SearchInputProps> = (props) => {
+ const { onChange, onButtonClick } = props;
+
+ const { t } = useTranslation();
+
+ const onInputChange = useCallback(
+ (event: React.ChangeEvent<HTMLInputElement>): void => {
+ onChange(event.currentTarget.value);
+ },
+ [onChange]
+ );
+
+ const onInputKeyPress = useCallback(
+ (event: React.KeyboardEvent<HTMLInputElement>): void => {
+ if (event.key === "Enter") {
+ onButtonClick();
+ }
+ },
+ [onButtonClick]
+ );
+
+ return (
+ <div className={clsx("form-inline my-2", props.className)}>
+ <Input
+ className="mr-sm-2"
+ value={props.value}
+ onChange={onInputChange}
+ onKeyPress={onInputKeyPress}
+ placeholder={props.placeholder}
+ />
+ <div className="mt-2 mt-sm-0 order-sm-last ml-sm-3">
+ {props.additionalButton}
+ </div>
+ <div className="mt-2 mt-sm-0 ml-auto ml-sm-0">
+ {props.loading ? (
+ <Spinner />
+ ) : (
+ <Button outline color="primary" onClick={props.onButtonClick}>
+ {props.buttonText ?? t("search")}
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default SearchInput;
diff --git a/Timeline/ClientApp/src/app/common/TimelineLogo.tsx b/Timeline/ClientApp/src/app/common/TimelineLogo.tsx
index 1ec62021..27d188fc 100644
--- a/Timeline/ClientApp/src/app/common/TimelineLogo.tsx
+++ b/Timeline/ClientApp/src/app/common/TimelineLogo.tsx
@@ -1,26 +1,26 @@
-import React, { SVGAttributes } from 'react';
-
-export interface TimelineLogoProps extends SVGAttributes<SVGElement> {
- color?: string;
-}
-
-const TimelineLogo: React.FC<TimelineLogoProps> = props => {
- const { color, ...forwardProps } = props;
- const coercedColor = color ?? 'currentcolor';
- return (
- <svg
- className={props.className}
- viewBox="0 0 100 100"
- fill="none"
- strokeWidth="12"
- stroke={coercedColor}
- {...forwardProps}
- >
- <line x1="50" y1="0" x2="50" y2="25" />
- <circle cx="50" cy="50" r="22" />
- <line x1="50" y1="75" x2="50" y2="100" />
- </svg>
- );
-};
-
-export default TimelineLogo;
+import React, { SVGAttributes } from "react";
+
+export interface TimelineLogoProps extends SVGAttributes<SVGElement> {
+ color?: string;
+}
+
+const TimelineLogo: React.FC<TimelineLogoProps> = (props) => {
+ const { color, ...forwardProps } = props;
+ const coercedColor = color ?? "currentcolor";
+ return (
+ <svg
+ className={props.className}
+ viewBox="0 0 100 100"
+ fill="none"
+ strokeWidth="12"
+ stroke={coercedColor}
+ {...forwardProps}
+ >
+ <line x1="50" y1="0" x2="50" y2="25" />
+ <circle cx="50" cy="50" r="22" />
+ <line x1="50" y1="75" x2="50" y2="100" />
+ </svg>
+ );
+};
+
+export default TimelineLogo;
diff --git a/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx b/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx
index 3b721985..29f6a69f 100644
--- a/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx
+++ b/Timeline/ClientApp/src/app/common/UserTimelineLogo.tsx
@@ -1,26 +1,26 @@
-import React, { SVGAttributes } from 'react';
-
-export interface UserTimelineLogoProps extends SVGAttributes<SVGElement> {
- color?: string;
-}
-
-const UserTimelineLogo: React.FC<UserTimelineLogoProps> = props => {
- const { color, ...forwardProps } = props;
- const coercedColor = color ?? 'currentcolor';
-
- return (
- <svg viewBox="0 0 100 100" {...forwardProps}>
- <g fill="none" stroke={coercedColor} strokeWidth="12">
- <line x1="50" x2="50" y1="0" y2="25" />
- <circle cx="50" cy="50" r="22" />
- <line x1="50" x2="50" y1="75" y2="100" />
- </g>
- <g fill={color}>
- <circle cx="85" cy="75" r="10" />
- <path d="m70,100c0,0 15,-30 30,0.25" />
- </g>
- </svg>
- );
-};
-
-export default UserTimelineLogo;
+import React, { SVGAttributes } from "react";
+
+export interface UserTimelineLogoProps extends SVGAttributes<SVGElement> {
+ color?: string;
+}
+
+const UserTimelineLogo: React.FC<UserTimelineLogoProps> = (props) => {
+ const { color, ...forwardProps } = props;
+ const coercedColor = color ?? "currentcolor";
+
+ return (
+ <svg viewBox="0 0 100 100" {...forwardProps}>
+ <g fill="none" stroke={coercedColor} strokeWidth="12">
+ <line x1="50" x2="50" y1="0" y2="25" />
+ <circle cx="50" cy="50" r="22" />
+ <line x1="50" x2="50" y1="75" y2="100" />
+ </g>
+ <g fill={color}>
+ <circle cx="85" cy="75" r="10" />
+ <path d="m70,100c0,0 15,-30 30,0.25" />
+ </g>
+ </svg>
+ );
+};
+
+export default UserTimelineLogo;
diff --git a/Timeline/ClientApp/src/app/common/alert-service.ts b/Timeline/ClientApp/src/app/common/alert-service.ts
index 957c5e10..e4c0e653 100644
--- a/Timeline/ClientApp/src/app/common/alert-service.ts
+++ b/Timeline/ClientApp/src/app/common/alert-service.ts
@@ -1,61 +1,61 @@
-import React from 'react';
-import pull from 'lodash/pull';
-
-export interface AlertInfo {
- type?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
- message: string | React.FC<unknown> | { type: 'i18n'; key: string };
- dismissTime?: number | 'never';
-}
-
-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);
-}
+import React from "react";
+import pull from "lodash/pull";
+
+export interface AlertInfo {
+ type?: "primary" | "secondary" | "success" | "danger" | "warning" | "info";
+ message: string | React.FC<unknown> | { type: "i18n"; key: string };
+ dismissTime?: number | "never";
+}
+
+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/app/common/alert.sass b/Timeline/ClientApp/src/app/common/alert.sass
index c3560b87..5b6e65c2 100644
--- a/Timeline/ClientApp/src/app/common/alert.sass
+++ b/Timeline/ClientApp/src/app/common/alert.sass
@@ -1,15 +1,15 @@
-.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
+.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/app/common/common.sass b/Timeline/ClientApp/src/app/common/common.sass
index 78e6fd14..15d34d7c 100644
--- a/Timeline/ClientApp/src/app/common/common.sass
+++ b/Timeline/ClientApp/src/app/common/common.sass
@@ -1,33 +1,33 @@
-.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
+.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