diff options
Diffstat (limited to 'FrontEnd/src/app/views/user')
| -rw-r--r-- | FrontEnd/src/app/views/user/ChangeAvatarDialog.tsx | 302 | ||||
| -rw-r--r-- | FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx | 28 | ||||
| -rw-r--r-- | FrontEnd/src/app/views/user/UserInfoCard.tsx | 80 | ||||
| -rw-r--r-- | FrontEnd/src/app/views/user/UserPageUI.tsx | 18 | ||||
| -rw-r--r-- | FrontEnd/src/app/views/user/index.tsx | 72 | ||||
| -rw-r--r-- | FrontEnd/src/app/views/user/user.sass | 7 | 
6 files changed, 507 insertions, 0 deletions
diff --git a/FrontEnd/src/app/views/user/ChangeAvatarDialog.tsx b/FrontEnd/src/app/views/user/ChangeAvatarDialog.tsx new file mode 100644 index 00000000..ffa2218b --- /dev/null +++ b/FrontEnd/src/app/views/user/ChangeAvatarDialog.tsx @@ -0,0 +1,302 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { AxiosError } from "axios"; +import { Modal, Row, Button } from "react-bootstrap"; + +import { UiLogicError } from "@/common"; + +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 close = 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 show={props.open} onHide={close}> +      <Modal.Header> +        <Modal.Title> {t("userPage.dialogChangeAvatar.title")}</Modal.Title> +      </Modal.Header> +      {(() => { +        if (state === "select") { +          return ( +            <> +              <Modal.Body className="container"> +                <Row>{t("userPage.dialogChangeAvatar.prompt.select")}</Row> +                <Row> +                  <input type="file" accept="image/*" onChange={onSelectFile} /> +                </Row> +              </Modal.Body> +              <Modal.Footer> +                <Button variant="secondary" onClick={close}> +                  {t("operationDialog.cancel")} +                </Button> +              </Modal.Footer> +            </> +          ); +        } else if (state === "crop") { +          if (fileUrl == null) { +            throw new UiLogicError(); +          } +          return ( +            <> +              <Modal.Body className="container"> +                <Row className="justify-content-center"> +                  <ImageCropper +                    clip={clip} +                    onChange={setClip} +                    imageUrl={fileUrl} +                    imageElementCallback={setCropImgElement} +                  /> +                </Row> +                <Row>{t("userPage.dialogChangeAvatar.prompt.crop")}</Row> +              </Modal.Body> +              <Modal.Footer> +                <Button variant="secondary" onClick={close}> +                  {t("operationDialog.cancel")} +                </Button> +                <Button variant="secondary" onClick={onCropPrevious}> +                  {t("operationDialog.previousStep")} +                </Button> +                <Button +                  color="primary" +                  onClick={onCropNext} +                  disabled={ +                    cropImgElement == null || clip == null || clip.width === 0 +                  } +                > +                  {t("operationDialog.nextStep")} +                </Button> +              </Modal.Footer> +            </> +          ); +        } else if (state === "processcrop") { +          return ( +            <> +              <Modal.Body className="container"> +                <Row> +                  {t("userPage.dialogChangeAvatar.prompt.processingCrop")} +                </Row> +              </Modal.Body> +              <Modal.Footer> +                <Button variant="secondary" onClick={close}> +                  {t("operationDialog.cancel")} +                </Button> +                <Button variant="secondary" onClick={onPreviewPrevious}> +                  {t("operationDialog.previousStep")} +                </Button> +              </Modal.Footer> +            </> +          ); +        } else if (state === "preview") { +          return ( +            <> +              <Modal.Body className="container"> +                {createPreviewRow()} +                <Row>{t("userPage.dialogChangeAvatar.prompt.preview")}</Row> +              </Modal.Body> +              <Modal.Footer> +                <Button variant="secondary" onClick={close}> +                  {t("operationDialog.cancel")} +                </Button> +                <Button variant="secondary" onClick={onPreviewPrevious}> +                  {t("operationDialog.previousStep")} +                </Button> +                <Button variant="primary" onClick={upload}> +                  {t("userPage.dialogChangeAvatar.upload")} +                </Button> +              </Modal.Footer> +            </> +          ); +        } else if (state === "uploading") { +          return ( +            <> +              <Modal.Body className="container"> +                {createPreviewRow()} +                <Row>{t("userPage.dialogChangeAvatar.prompt.uploading")}</Row> +              </Modal.Body> +              <Modal.Footer></Modal.Footer> +            </> +          ); +        } else if (state === "success") { +          return ( +            <> +              <Modal.Body className="container"> +                <Row className="p-4 text-success"> +                  {t("operationDialog.success")} +                </Row> +              </Modal.Body> +              <Modal.Footer> +                <Button variant="success" onClick={close}> +                  {t("operationDialog.ok")} +                </Button> +              </Modal.Footer> +            </> +          ); +        } else { +          return ( +            <> +              <Modal.Body className="container"> +                {createPreviewRow()} +                <Row className="text-danger">{trueMessage}</Row> +              </Modal.Body> +              <Modal.Footer> +                <Button variant="secondary" onClick={close}> +                  {t("operationDialog.cancel")} +                </Button> +                <Button variant="primary" onClick={upload}> +                  {t("operationDialog.retry")} +                </Button> +              </Modal.Footer> +            </> +          ); +        } +      })()} +    </Modal> +  ); +}; + +export default ChangeAvatarDialog; diff --git a/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx b/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx new file mode 100644 index 00000000..251b18c5 --- /dev/null +++ b/FrontEnd/src/app/views/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/FrontEnd/src/app/views/user/UserInfoCard.tsx b/FrontEnd/src/app/views/user/UserInfoCard.tsx new file mode 100644 index 00000000..888fb18a --- /dev/null +++ b/FrontEnd/src/app/views/user/UserInfoCard.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Dropdown, Button } from "react-bootstrap"; + +import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline"; +import { useAvatar } from "@/services/user"; + +import BlobImage from "../common/BlobImage"; +import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI"; +import InfoCardTemplate from "../timeline-common/InfoCardTemplate"; + +export type PersonalTimelineManageItem = "avatar" | "nickname"; + +export type UserInfoCardProps = TimelineCardComponentProps< +  PersonalTimelineManageItem +>; + +const UserInfoCard: React.FC<UserInfoCardProps> = (props) => { +  const { +    timeline, +    collapse, +    onMember, +    onManage, +    syncStatus, +    toggleCollapse, +  } = props; +  const { t } = useTranslation(); + +  const avatar = useAvatar(timeline?.owner?.username); + +  return ( +    <InfoCardTemplate +      className={props.className} +      syncStatus={syncStatus} +      collapse={collapse} +      toggleCollapse={toggleCollapse} +    > +      <div> +        <BlobImage blob={avatar} className="avatar" /> +        {timeline.owner.nickname} +        <small className="ml-3 text-secondary"> +          @{timeline.owner.username} +        </small> +      </div> +      <p className="mb-0">{timeline.description}</p> +      <small className="mt-1 d-block"> +        {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} +      </small> +      <div className="text-right mt-2"> +        {onManage != null ? ( +          <Dropdown> +            <Dropdown.Toggle variant="outline-primary"> +              {t("timeline.manage")} +            </Dropdown.Toggle> +            <Dropdown.Menu> +              <Dropdown.Item onClick={() => onManage("nickname")}> +                {t("timeline.manageItem.nickname")} +              </Dropdown.Item> +              <Dropdown.Item onClick={() => onManage("avatar")}> +                {t("timeline.manageItem.avatar")} +              </Dropdown.Item> +              <Dropdown.Item onClick={() => onManage("property")}> +                {t("timeline.manageItem.property")} +              </Dropdown.Item> +              <Dropdown.Item onClick={onMember}> +                {t("timeline.manageItem.member")} +              </Dropdown.Item> +            </Dropdown.Menu> +          </Dropdown> +        ) : ( +          <Button variant="outline-primary" onClick={onMember}> +            {t("timeline.memberButton")} +          </Button> +        )} +      </div> +    </InfoCardTemplate> +  ); +}; + +export default UserInfoCard; diff --git a/FrontEnd/src/app/views/user/UserPageUI.tsx b/FrontEnd/src/app/views/user/UserPageUI.tsx new file mode 100644 index 00000000..d405399c --- /dev/null +++ b/FrontEnd/src/app/views/user/UserPageUI.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +import TimelinePageTemplateUI, { +  TimelinePageTemplateUIProps, +} from "../timeline-common/TimelinePageTemplateUI"; + +import UserInfoCard, { PersonalTimelineManageItem } from "./UserInfoCard"; + +export type UserPageUIProps = Omit< +  TimelinePageTemplateUIProps<PersonalTimelineManageItem>, +  "CardComponent" +>; + +const UserPageUI: React.FC<UserPageUIProps> = (props) => { +  return <TimelinePageTemplateUI {...props} CardComponent={UserInfoCard} />; +}; + +export default UserPageUI; diff --git a/FrontEnd/src/app/views/user/index.tsx b/FrontEnd/src/app/views/user/index.tsx new file mode 100644 index 00000000..7c0b1563 --- /dev/null +++ b/FrontEnd/src/app/views/user/index.tsx @@ -0,0 +1,72 @@ +import React, { useState } from "react"; +import { useParams } from "react-router"; + +import { UiLogicError } from "@/common"; +import { useUser, userInfoService } from "@/services/user"; + +import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate"; + +import UserPageUI from "./UserPageUI"; +import { PersonalTimelineManageItem } from "./UserInfoCard"; +import ChangeNicknameDialog from "./ChangeNicknameDialog"; +import ChangeAvatarDialog from "./ChangeAvatarDialog"; + +const UserPage: React.FC = (_) => { +  const { username } = useParams<{ username: string }>(); + +  const user = useUser(); + +  const [dialog, setDialog] = useState<null | PersonalTimelineManageItem>(null); + +  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) => +          userInfoService.setNickname(username, newNickname) +        } +      /> +    ); +  } else if (dialog === "avatar") { +    if (user == null) { +      throw new UiLogicError("Change avatar without login."); +    } + +    dialogElement = ( +      <ChangeAvatarDialog +        open +        close={closeDialogHandler} +        process={(file) => userInfoService.setAvatar(username, file)} +      /> +    ); +  } + +  const onManage = React.useCallback((item: PersonalTimelineManageItem) => { +    setDialog(item); +  }, []); + +  return ( +    <> +      <TimelinePageTemplate +        name={`@${username}`} +        UiComponent={UserPageUI} +        onManage={onManage} +        notFoundI18nKey="timeline.userNotExist" +      /> +      {dialogElement} +    </> +  ); +}; + +export default UserPage; diff --git a/FrontEnd/src/app/views/user/user.sass b/FrontEnd/src/app/views/user/user.sass new file mode 100644 index 00000000..63a28e05 --- /dev/null +++ b/FrontEnd/src/app/views/user/user.sass @@ -0,0 +1,7 @@ +.change-avatar-cropper-row
 +  max-height: 400px
 +
 +.change-avatar-img
 +  min-width: 50%
 +  max-width: 100%
 +  max-height: 400px
  | 
