aboutsummaryrefslogtreecommitdiff
path: root/Timeline/ClientApp/src/user
diff options
context:
space:
mode:
Diffstat (limited to 'Timeline/ClientApp/src/user')
-rw-r--r--Timeline/ClientApp/src/user/ChangeAvatarDialog.tsx305
-rw-r--r--Timeline/ClientApp/src/user/ChangeNicknameDialog.tsx28
-rw-r--r--Timeline/ClientApp/src/user/Login.tsx146
-rw-r--r--Timeline/ClientApp/src/user/User.tsx70
-rw-r--r--Timeline/ClientApp/src/user/UserInfoCard.tsx126
-rw-r--r--Timeline/ClientApp/src/user/UserPage.tsx20
-rw-r--r--Timeline/ClientApp/src/user/api.ts78
-rw-r--r--Timeline/ClientApp/src/user/user-page.scss13
8 files changed, 786 insertions, 0 deletions
diff --git a/Timeline/ClientApp/src/user/ChangeAvatarDialog.tsx b/Timeline/ClientApp/src/user/ChangeAvatarDialog.tsx
new file mode 100644
index 00000000..e082d5a0
--- /dev/null
+++ b/Timeline/ClientApp/src/user/ChangeAvatarDialog.tsx
@@ -0,0 +1,305 @@
+import React, { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ Modal,
+ ModalHeader,
+ Row,
+ Button,
+ ModalBody,
+ ModalFooter,
+} from 'reactstrap';
+import { AxiosError } from 'axios';
+
+import ImageCropper, { Clip, applyClipToImage } from '../common/ImageCropper';
+
+export interface ChangeAvatarDialogProps {
+ open: boolean;
+ close: () => void;
+ process: (blob: Blob) => Promise<void>;
+}
+
+const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
+ const { t } = useTranslation();
+
+ const [file, setFile] = React.useState<File | null>(null);
+ const [fileUrl, setFileUrl] = React.useState<string | null>(null);
+ const [clip, setClip] = React.useState<Clip | null>(null);
+ const [
+ cropImgElement,
+ setCropImgElement,
+ ] = React.useState<HTMLImageElement | null>(null);
+ const [resultBlob, setResultBlob] = React.useState<Blob | null>(null);
+ const [resultUrl, setResultUrl] = React.useState<string | null>(null);
+
+ const [state, setState] = React.useState<
+ | 'select'
+ | 'crop'
+ | 'processcrop'
+ | 'preview'
+ | 'uploading'
+ | 'success'
+ | 'error'
+ >('select');
+
+ const [message, setMessage] = useState<
+ string | { type: 'custom'; text: string } | null
+ >('userPage.dialogChangeAvatar.prompt.select');
+
+ const trueMessage =
+ message == null
+ ? null
+ : typeof message === 'string'
+ ? t(message)
+ : message.text;
+
+ const closeDialog = props.close;
+
+ const toggle = React.useCallback((): void => {
+ if (!(state === 'uploading')) {
+ closeDialog();
+ }
+ }, [state, closeDialog]);
+
+ useEffect(() => {
+ if (file != null) {
+ const url = URL.createObjectURL(file);
+ setClip(null);
+ setFileUrl(url);
+ setState('crop');
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setFileUrl(null);
+ setState('select');
+ }
+ }, [file]);
+
+ React.useEffect(() => {
+ if (resultBlob != null) {
+ const url = URL.createObjectURL(resultBlob);
+ setResultUrl(url);
+ setState('preview');
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setResultUrl(null);
+ }
+ }, [resultBlob]);
+
+ const onSelectFile = React.useCallback(
+ (e: React.ChangeEvent<HTMLInputElement>): void => {
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ setFile(null);
+ } else {
+ setFile(files[0]);
+ }
+ },
+ []
+ );
+
+ const onCropNext = React.useCallback(() => {
+ if (
+ cropImgElement == null ||
+ clip == null ||
+ clip.width === 0 ||
+ file == null
+ ) {
+ throw new Error('Invalid state.');
+ }
+
+ setState('processcrop');
+ applyClipToImage(cropImgElement, clip, file.type).then((b) => {
+ setResultBlob(b);
+ });
+ }, [cropImgElement, clip, file]);
+
+ const onCropPrevious = React.useCallback(() => {
+ setFile(null);
+ setState('select');
+ }, []);
+
+ const onPreviewPrevious = React.useCallback(() => {
+ setResultBlob(null);
+ setState('crop');
+ }, []);
+
+ const process = props.process;
+
+ const upload = React.useCallback(() => {
+ if (resultBlob == null) {
+ throw new Error('Invalid state.');
+ }
+
+ setState('uploading');
+ process(resultBlob).then(
+ () => {
+ setState('success');
+ },
+ (e: unknown) => {
+ setState('error');
+ setMessage({ type: 'custom', text: (e as AxiosError).message });
+ }
+ );
+ }, [resultBlob, process]);
+
+ const createPreviewRow = (): React.ReactElement => {
+ if (resultUrl == null) {
+ throw new Error('Invalid state.');
+ }
+ return (
+ <Row className="justify-content-center">
+ <img
+ className="change-avatar-img"
+ src={resultUrl}
+ alt={t('userPage.dialogChangeAvatar.previewImgAlt')}
+ />
+ </Row>
+ );
+ };
+
+ return (
+ <Modal isOpen={props.open} toggle={toggle}>
+ <ModalHeader> {t('userPage.dialogChangeAvatar.title')}</ModalHeader>
+ {(() => {
+ if (state === 'select') {
+ return (
+ <>
+ <ModalBody className="container">
+ <Row>{t('userPage.dialogChangeAvatar.prompt.select')}</Row>
+ <Row>
+ <input type="file" accept="image/*" onChange={onSelectFile} />
+ </Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === 'crop') {
+ if (fileUrl == null) {
+ throw new Error('Invalid state.');
+ }
+ return (
+ <>
+ <ModalBody className="container">
+ <Row className="justify-content-center">
+ <ImageCropper
+ clip={clip}
+ onChange={setClip}
+ imageUrl={fileUrl}
+ imageElementCallback={setCropImgElement}
+ />
+ </Row>
+ <Row>{t('userPage.dialogChangeAvatar.prompt.crop')}</Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button color="secondary" onClick={onCropPrevious}>
+ {t('operationDialog.previousStep')}
+ </Button>
+ <Button
+ color="primary"
+ onClick={onCropNext}
+ disabled={
+ cropImgElement == null || clip == null || clip.width === 0
+ }
+ >
+ {t('operationDialog.nextStep')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === 'processcrop') {
+ return (
+ <>
+ <ModalBody className="container">
+ <Row>
+ {t('userPage.dialogChangeAvatar.prompt.processingCrop')}
+ </Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button color="secondary" onClick={onPreviewPrevious}>
+ {t('operationDialog.previousStep')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === 'preview') {
+ return (
+ <>
+ <ModalBody className="container">
+ {createPreviewRow()}
+ <Row>{t('userPage.dialogChangeAvatar.prompt.preview')}</Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button color="secondary" onClick={onPreviewPrevious}>
+ {t('operationDialog.previousStep')}
+ </Button>
+ <Button color="primary" onClick={upload}>
+ {t('userPage.dialogChangeAvatar.upload')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else if (state === 'uploading') {
+ return (
+ <>
+ <ModalBody className="container">
+ {createPreviewRow()}
+ <Row>{t('userPage.dialogChangeAvatar.prompt.uploading')}</Row>
+ </ModalBody>
+ <ModalFooter></ModalFooter>
+ </>
+ );
+ } else if (state === 'success') {
+ return (
+ <>
+ <ModalBody className="container">
+ <Row className="p-4 text-success">
+ {t('operationDialog.success')}
+ </Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="success" onClick={toggle}>
+ {t('operationDialog.ok')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ } else {
+ return (
+ <>
+ <ModalBody className="container">
+ {createPreviewRow()}
+ <Row className="text-danger">{trueMessage}</Row>
+ </ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={toggle}>
+ {t('operationDialog.cancel')}
+ </Button>
+ <Button color="primary" onClick={upload}>
+ {t('operationDialog.retry')}
+ </Button>
+ </ModalFooter>
+ </>
+ );
+ }
+ })()}
+ </Modal>
+ );
+};
+
+export default ChangeAvatarDialog;
diff --git a/Timeline/ClientApp/src/user/ChangeNicknameDialog.tsx b/Timeline/ClientApp/src/user/ChangeNicknameDialog.tsx
new file mode 100644
index 00000000..393d88f6
--- /dev/null
+++ b/Timeline/ClientApp/src/user/ChangeNicknameDialog.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+
+import OperationDialog from '../common/OperationDialog';
+
+export interface ChangeNicknameDialogProps {
+ open: boolean;
+ close: () => void;
+ onProcess: (newNickname: string) => Promise<void>;
+}
+
+const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = props => {
+ return (
+ <OperationDialog
+ open={props.open}
+ title="userPage.dialogChangeNickname.title"
+ titleColor="default"
+ inputScheme={[
+ { type: 'text', label: 'userPage.dialogChangeNickname.inputLabel' }
+ ]}
+ onProcess={([newNickname]) => {
+ return props.onProcess(newNickname as string);
+ }}
+ close={props.close}
+ />
+ );
+};
+
+export default ChangeNicknameDialog;
diff --git a/Timeline/ClientApp/src/user/Login.tsx b/Timeline/ClientApp/src/user/Login.tsx
new file mode 100644
index 00000000..fc9364c0
--- /dev/null
+++ b/Timeline/ClientApp/src/user/Login.tsx
@@ -0,0 +1,146 @@
+import React, { Fragment, useState, useEffect } from 'react';
+import { useHistory } from 'react-router';
+import { useTranslation } from 'react-i18next';
+
+import AppBar from '../common/AppBar';
+
+import { userLogin, useUser } from '../data/user';
+import {
+ Label,
+ FormGroup,
+ Input,
+ Form,
+ FormFeedback,
+ Spinner,
+ Button,
+} from 'reactstrap';
+
+const Login: React.FC = (_) => {
+ const { t } = useTranslation();
+ const history = useHistory();
+ const [username, setUsername] = useState<string>('');
+ const [usernameDirty, setUsernameDirty] = useState<boolean>(false);
+ const [password, setPassword] = useState<string>('');
+ const [passwordDirty, setPasswordDirty] = useState<boolean>(false);
+ const [rememberMe, setRememberMe] = useState<boolean>(true);
+ const [process, setProcess] = useState<boolean>(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const user = useUser();
+
+ useEffect(() => {
+ if (user != null) {
+ const id = setTimeout(() => history.push('/'), 3000);
+ return () => {
+ clearTimeout(id);
+ };
+ }
+ }, [history, user]);
+
+ if (user != null) {
+ return (
+ <>
+ <AppBar />
+ <p className="mt-appbar">{t('login.alreadyLogin')}</p>
+ </>
+ );
+ }
+
+ function onSubmit(event: React.SyntheticEvent): void {
+ if (username === '' || password === '') {
+ setUsernameDirty(true);
+ setPasswordDirty(true);
+ return;
+ }
+
+ setProcess(true);
+ userLogin(
+ {
+ username: username,
+ password: password,
+ },
+ rememberMe
+ ).then(
+ (_) => {
+ if (history.length === 0) {
+ history.push('/');
+ } else {
+ history.goBack();
+ }
+ },
+ (e) => {
+ setProcess(false);
+ setError(e.message);
+ }
+ );
+ event.preventDefault();
+ }
+
+ return (
+ <Fragment>
+ <AppBar />
+ <div className="container login-container mt-appbar">
+ <h1>{t('welcome')}</h1>
+ <Form>
+ <FormGroup>
+ <Label for="username">{t('user.username')}</Label>
+ <Input
+ id="username"
+ disabled={process}
+ onChange={(e) => {
+ setUsername(e.target.value);
+ setUsernameDirty(true);
+ }}
+ value={username}
+ invalid={usernameDirty && username === ''}
+ />
+ {usernameDirty && username === '' && (
+ <FormFeedback>{t('login.emptyUsername')}</FormFeedback>
+ )}
+ </FormGroup>
+ <FormGroup>
+ <Label for="password">{t('user.password')}</Label>
+ <Input
+ id="password"
+ type="password"
+ disabled={process}
+ onChange={(e) => {
+ setPassword(e.target.value);
+ setPasswordDirty(true);
+ }}
+ value={password}
+ invalid={passwordDirty && password === ''}
+ />
+ {passwordDirty && password === '' && (
+ <FormFeedback>{t('login.emptyPassword')}</FormFeedback>
+ )}
+ </FormGroup>
+ <FormGroup check>
+ <Input
+ id="remember-me"
+ type="checkbox"
+ checked={rememberMe}
+ onChange={(e) => {
+ const v = (e.target as HTMLInputElement).checked;
+ setRememberMe(v);
+ }}
+ />
+ <Label for="remember-me">{t('user.rememberMe')}</Label>
+ </FormGroup>
+ {error ? <p className="text-error">{t(error)}</p> : null}
+ <div>
+ {process ? (
+ <Spinner />
+ ) : (
+ <Button color="primary" onClick={onSubmit}>
+ {t('user.login')}
+ </Button>
+ )}
+ </div>
+ </Form>
+ </div>
+ </Fragment>
+ );
+};
+
+export default Login;
diff --git a/Timeline/ClientApp/src/user/User.tsx b/Timeline/ClientApp/src/user/User.tsx
new file mode 100644
index 00000000..7bdd64b7
--- /dev/null
+++ b/Timeline/ClientApp/src/user/User.tsx
@@ -0,0 +1,70 @@
+import React, { useState } from 'react';
+import { useParams } from 'react-router';
+
+import { useUser } from '../data/user';
+import { changeNickname, changeAvatar } from './api';
+import { personalTimelineService } from '../data/timeline';
+
+import UserPage from './UserPage';
+import ChangeNicknameDialog from './ChangeNicknameDialog';
+import ChangeAvatarDialog from './ChangeAvatarDialog';
+import TimelinePageTemplate from '../timeline/TimelinePageTemplate';
+import { PersonalTimelineManageItem } from './UserInfoCard';
+
+const User: React.FC = (_) => {
+ const { username } = useParams<{ username: string }>();
+
+ const user = useUser();
+
+ const [dialog, setDialog] = useState<null | PersonalTimelineManageItem>(null);
+ const [dataKey, setDataKey] = useState<number>(0);
+
+ let dialogElement: React.ReactElement | undefined;
+
+ const closeDialogHandler = (): void => {
+ setDialog(null);
+ };
+
+ if (dialog === 'nickname') {
+ dialogElement = (
+ <ChangeNicknameDialog
+ open
+ close={closeDialogHandler}
+ onProcess={(newNickname) => {
+ const p = changeNickname(user!.token, username, newNickname);
+ return p.then((_) => {
+ setDataKey(dataKey + 1);
+ });
+ }}
+ />
+ );
+ } else if (dialog === 'avatar') {
+ dialogElement = (
+ <ChangeAvatarDialog
+ open
+ close={closeDialogHandler}
+ process={(file) => changeAvatar(user!.token, username, file, file.type)}
+ />
+ );
+ }
+
+ const onManage = React.useCallback((item: PersonalTimelineManageItem) => {
+ setDialog(item);
+ }, []);
+
+ return (
+ <>
+ <TimelinePageTemplate
+ dataVersion={dataKey}
+ name={username}
+ UiComponent={UserPage}
+ onManage={onManage}
+ service={personalTimelineService}
+ notFoundI18nKey="timeline.userNotExist"
+ />
+ {dialogElement}
+ </>
+ );
+};
+
+export default User;
diff --git a/Timeline/ClientApp/src/user/UserInfoCard.tsx b/Timeline/ClientApp/src/user/UserInfoCard.tsx
new file mode 100644
index 00000000..280cddce
--- /dev/null
+++ b/Timeline/ClientApp/src/user/UserInfoCard.tsx
@@ -0,0 +1,126 @@
+import React from 'react';
+import clsx from 'clsx';
+import {
+ Dropdown,
+ DropdownToggle,
+ DropdownMenu,
+ DropdownItem,
+ Button,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import { fromEvent } from 'rxjs';
+
+import {
+ TimelineInfo,
+ timelineVisibilityTooltipTranslationMap,
+} from '../data/timeline';
+import { useAvatarVersion, useAvatarUrlWithGivenVersion } from './api';
+import { useUser } from '../data/user';
+
+import { TimelineCardComponentProps } from '../timeline/TimelinePageTemplateUI';
+
+export type PersonalTimelineManageItem = 'avatar' | 'nickname';
+
+export type UserInfoCardProps = TimelineCardComponentProps<
+ TimelineInfo,
+ PersonalTimelineManageItem
+>;
+
+const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
+ const { onHeight, onManage } = props;
+ const { t } = useTranslation();
+ const user = useUser();
+
+ const avatarVersion = useAvatarVersion();
+ const avatarUrl = useAvatarUrlWithGivenVersion(
+ user != null && user.username === props.timeline.owner.username
+ ? avatarVersion
+ : undefined,
+ props.timeline.owner._links.avatar
+ );
+
+ const containerRef = React.useRef<HTMLDivElement>(null!);
+
+ const notifyHeight = React.useCallback((): void => {
+ if (onHeight) {
+ onHeight(containerRef.current.getBoundingClientRect().height);
+ }
+ }, [onHeight]);
+
+ React.useEffect(() => {
+ const subscription = fromEvent(window, 'resize').subscribe(notifyHeight);
+ return () => subscription.unsubscribe();
+ });
+
+ const [manageDropdownOpen, setManageDropdownOpen] = React.useState<boolean>(
+ false
+ );
+ const toggleManageDropdown = React.useCallback(
+ (): void => setManageDropdownOpen((old) => !old),
+ []
+ );
+ const onManageProperty = React.useCallback(
+ (): void => onManage!('property'),
+ [onManage]
+ );
+ const onManageAvatar = React.useCallback((): void => onManage!('avatar'), [
+ onManage,
+ ]);
+ const onManageNickname = React.useCallback(
+ (): void => onManage!('nickname'),
+ [onManage]
+ );
+
+ return (
+ <div
+ ref={containerRef}
+ className={clsx('rounded border bg-light p-2', props.className)}
+ onTransitionEnd={notifyHeight}
+ >
+ <img
+ src={avatarUrl}
+ onLoad={notifyHeight}
+ className="avatar large mr-2 mb-2 rounded-circle float-left"
+ />
+ <div>
+ {props.timeline.owner.nickname}
+ <small className="ml-3 text-secondary">
+ @{props.timeline.owner.username}
+ </small>
+ </div>
+ <p className="mb-0">{props.timeline.description}</p>
+ <small className="mt-1 d-block">
+ {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])}
+ </small>
+ <div className="text-right mt-2">
+ {props.onManage != null ? (
+ <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}>
+ <DropdownToggle outline color="primary">
+ {t('timeline.manage')}
+ </DropdownToggle>
+ <DropdownMenu>
+ <DropdownItem onClick={onManageNickname}>
+ {t('timeline.manageItem.nickname')}
+ </DropdownItem>
+ <DropdownItem onClick={onManageAvatar}>
+ {t('timeline.manageItem.avatar')}
+ </DropdownItem>
+ <DropdownItem onClick={onManageProperty}>
+ {t('timeline.manageItem.property')}
+ </DropdownItem>
+ <DropdownItem onClick={props.onMember}>
+ {t('timeline.manageItem.member')}
+ </DropdownItem>
+ </DropdownMenu>
+ </Dropdown>
+ ) : (
+ <Button color="primary" outline onClick={props.onMember}>
+ {t('timeline.memberButton')}
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default UserInfoCard;
diff --git a/Timeline/ClientApp/src/user/UserPage.tsx b/Timeline/ClientApp/src/user/UserPage.tsx
new file mode 100644
index 00000000..723415c7
--- /dev/null
+++ b/Timeline/ClientApp/src/user/UserPage.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+import { ExcludeKey } from '../type-utilities';
+import { TimelineInfo } from '../data/timeline';
+
+import TimelinePageTemplateUI, {
+ TimelinePageTemplateUIProps
+} from '../timeline/TimelinePageTemplateUI';
+import UserInfoCard, { PersonalTimelineManageItem } from './UserInfoCard';
+
+export type UserPageProps = ExcludeKey<
+ TimelinePageTemplateUIProps<TimelineInfo, PersonalTimelineManageItem>,
+ 'CardComponent'
+>;
+
+const UserPage: React.FC<UserPageProps> = props => {
+ return <TimelinePageTemplateUI {...props} CardComponent={UserInfoCard} />;
+};
+
+export default UserPage;
diff --git a/Timeline/ClientApp/src/user/api.ts b/Timeline/ClientApp/src/user/api.ts
new file mode 100644
index 00000000..184c011f
--- /dev/null
+++ b/Timeline/ClientApp/src/user/api.ts
@@ -0,0 +1,78 @@
+import React from 'react';
+import axios from 'axios';
+import { BehaviorSubject, Observable } from 'rxjs';
+
+import { apiBaseUrl } from '../config';
+import { User } from '../data/user';
+import { updateQueryString } from '../helper';
+
+export function changeNickname(
+ token: string,
+ username: string,
+ newNickname: string
+): Promise<User> {
+ return axios
+ .patch<User>(`${apiBaseUrl}/users/${username}?token=${token}`, {
+ nickname: newNickname,
+ })
+ .then((res) => res.data);
+}
+
+const avatarVersionSubject = new BehaviorSubject<number | undefined>(undefined);
+
+export function changeAvatar(
+ token: string,
+ username: string,
+ data: Blob,
+ type: string
+): Promise<void> {
+ return axios
+ .put(`${apiBaseUrl}/users/${username}/avatar?token=${token}`, data, {
+ headers: {
+ 'Content-Type': type,
+ },
+ })
+ .then(() => {
+ avatarVersionSubject.next((avatarVersionSubject.value ?? 0) + 1);
+ });
+}
+
+export const avatarVersion$: Observable<
+ number | undefined
+> = avatarVersionSubject;
+
+export function useAvatarVersion(): number | undefined {
+ const [version, setVersion] = React.useState<number | undefined>();
+ React.useEffect(() => {
+ const subscription = avatarVersion$.subscribe((v) => setVersion(v));
+ return () => subscription.unsubscribe();
+ }, []);
+ return version;
+}
+
+export function useOptionalVersionedAvatarUrl(
+ url: string | undefined
+): string | undefined {
+ const avatarVersion = useAvatarVersion();
+ return React.useMemo(
+ () =>
+ url == null
+ ? undefined
+ : updateQueryString(
+ 'v',
+ avatarVersion == null ? null : avatarVersion + '',
+ url
+ ),
+ [avatarVersion, url]
+ );
+}
+
+export function useAvatarUrlWithGivenVersion(
+ version: number | null | undefined,
+ url: string
+): string {
+ return React.useMemo(
+ () => updateQueryString('v', version == null ? null : version + '', url),
+ [version, url]
+ );
+}
diff --git a/Timeline/ClientApp/src/user/user-page.scss b/Timeline/ClientApp/src/user/user-page.scss
new file mode 100644
index 00000000..a102fbe6
--- /dev/null
+++ b/Timeline/ClientApp/src/user/user-page.scss
@@ -0,0 +1,13 @@
+.login-container {
+ max-width: 600px;
+}
+
+.change-avatar-cropper-row {
+ max-height: 400px;
+}
+
+.change-avatar-img {
+ min-width: 50%;
+ max-width: 100%;
+ max-height: 400px;
+}