diff options
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, 0 insertions, 786 deletions
diff --git a/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx b/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx deleted file mode 100644 index a0e56621..00000000 --- a/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx +++ /dev/null @@ -1,306 +0,0 @@ -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 deleted file mode 100644 index dbc216e1..00000000 --- a/Timeline/ClientApp/src/app/user/ChangeNicknameDialog.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index f8b3f0e7..00000000 --- a/Timeline/ClientApp/src/app/user/Login.tsx +++ /dev/null @@ -1,147 +0,0 @@ -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 deleted file mode 100644 index 0e1977b1..00000000 --- a/Timeline/ClientApp/src/app/user/User.tsx +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index b4924a5d..00000000 --- a/Timeline/ClientApp/src/app/user/UserInfoCard.tsx +++ /dev/null @@ -1,116 +0,0 @@ -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 deleted file mode 100644 index 98fd06f8..00000000 --- a/Timeline/ClientApp/src/app/user/UserPage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index a9855eb0..00000000 --- a/Timeline/ClientApp/src/app/user/api.ts +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index fe3ab70b..00000000 --- a/Timeline/ClientApp/src/app/user/user-page.sass +++ /dev/null @@ -1,11 +0,0 @@ -.login-container - max-width: 600px - -.change-avatar-cropper-row - max-height: 400px - -.change-avatar-img - min-width: 50% - max-width: 100% - max-height: 400px - |