diff options
author | crupest <crupest@outlook.com> | 2020-06-11 17:27:15 +0800 |
---|---|---|
committer | crupest <crupest@outlook.com> | 2020-06-11 17:27:15 +0800 |
commit | 93ce8560fa19c3a91de99643fdbbe4f895a47b84 (patch) | |
tree | 66a9e6f1bbbbd5a0a25c13a0e51e7a7c1225871c /Timeline/ClientApp/src/app/user | |
parent | 6893a1c1e43b8fc17eaaba72db90494d946b5091 (diff) | |
download | timeline-93ce8560fa19c3a91de99643fdbbe4f895a47b84.tar.gz timeline-93ce8560fa19c3a91de99643fdbbe4f895a47b84.tar.bz2 timeline-93ce8560fa19c3a91de99643fdbbe4f895a47b84.zip |
feat(front): Service worker is coming!
Diffstat (limited to 'Timeline/ClientApp/src/app/user')
-rw-r--r-- | Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx | 306 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx | 28 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/user/Login.tsx | 147 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/user/User.tsx | 79 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/user/UserInfoCard.tsx | 116 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/user/UserPage.tsx | 20 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/user/api.ts | 79 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/user/user-page.sass | 11 |
8 files changed, 786 insertions, 0 deletions
diff --git a/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx b/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx new file mode 100644 index 00000000..a0e56621 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx @@ -0,0 +1,306 @@ +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'; +import { UiLogicError } from '../common'; + +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 UiLogicError(); + } + + setState('processcrop'); + void 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 UiLogicError(); + } + + 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 UiLogicError(); + } + 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 UiLogicError(); + } + 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/app/user/ChangeNicknameDialog.tsx b/Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx new file mode 100644 index 00000000..dbc216e1 --- /dev/null +++ b/Timeline/ClientApp/src/app/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/app/user/Login.tsx b/Timeline/ClientApp/src/app/user/Login.tsx new file mode 100644 index 00000000..f8b3f0e7 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/Login.tsx @@ -0,0 +1,147 @@ +import React, { Fragment, useState, useEffect } from 'react'; +import { useHistory } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { AxiosError } from 'axios'; + +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: AxiosError | Error) => { + 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/app/user/User.tsx b/Timeline/ClientApp/src/app/user/User.tsx new file mode 100644 index 00000000..0e1977b1 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/User.tsx @@ -0,0 +1,79 @@ +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'; +import { UiLogicError } from '../common'; + +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') { + if (user == null) { + throw new UiLogicError('Change nickname without login.'); + } + + dialogElement = ( + <ChangeNicknameDialog + open + close={closeDialogHandler} + onProcess={(newNickname) => { + const p = changeNickname(user.token, username, newNickname); + return p.then((_) => { + setDataKey(dataKey + 1); + }); + }} + /> + ); + } else if (dialog === 'avatar') { + if (user == null) { + throw new UiLogicError('Change avatar without login.'); + } + + 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/app/user/UserInfoCard.tsx b/Timeline/ClientApp/src/app/user/UserInfoCard.tsx new file mode 100644 index 00000000..b4924a5d --- /dev/null +++ b/Timeline/ClientApp/src/app/user/UserInfoCard.tsx @@ -0,0 +1,116 @@ +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 + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + 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), + [] + ); + + 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"> + {onManage != null ? ( + <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}> + <DropdownToggle outline color="primary"> + {t('timeline.manage')} + </DropdownToggle> + <DropdownMenu> + <DropdownItem onClick={() => onManage('nickname')}> + {t('timeline.manageItem.nickname')} + </DropdownItem> + <DropdownItem onClick={() => onManage('avatar')}> + {t('timeline.manageItem.avatar')} + </DropdownItem> + <DropdownItem onClick={() => onManage('property')}> + {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/app/user/UserPage.tsx b/Timeline/ClientApp/src/app/user/UserPage.tsx new file mode 100644 index 00000000..98fd06f8 --- /dev/null +++ b/Timeline/ClientApp/src/app/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/app/user/api.ts b/Timeline/ClientApp/src/app/user/api.ts new file mode 100644 index 00000000..a9855eb0 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/api.ts @@ -0,0 +1,79 @@ +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.toString(), + url + ), + [avatarVersion, url] + ); +} + +export function useAvatarUrlWithGivenVersion( + version: number | null | undefined, + url: string +): string { + return React.useMemo( + () => + updateQueryString('v', version == null ? null : version.toString(), url), + [version, url] + ); +} diff --git a/Timeline/ClientApp/src/app/user/user-page.sass b/Timeline/ClientApp/src/app/user/user-page.sass new file mode 100644 index 00000000..fe3ab70b --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-page.sass @@ -0,0 +1,11 @@ +.login-container + max-width: 600px + +.change-avatar-cropper-row + max-height: 400px + +.change-avatar-img + min-width: 50% + max-width: 100% + max-height: 400px + |