aboutsummaryrefslogtreecommitdiff
path: root/Timeline/ClientApp/src/app/views/user
diff options
context:
space:
mode:
Diffstat (limited to 'Timeline/ClientApp/src/app/views/user')
-rw-r--r--Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx307
-rw-r--r--Timeline/ClientApp/src/app/views/user/ChangeNicknameDialog.tsx28
-rw-r--r--Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx105
-rw-r--r--Timeline/ClientApp/src/app/views/user/UserPageUI.tsx18
-rw-r--r--Timeline/ClientApp/src/app/views/user/index.tsx72
-rw-r--r--Timeline/ClientApp/src/app/views/user/user.sass10
6 files changed, 540 insertions, 0 deletions
diff --git a/Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx b/Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx
new file mode 100644
index 00000000..1dd2ee8b
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx
@@ -0,0 +1,307 @@
+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 { 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 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/views/user/ChangeNicknameDialog.tsx b/Timeline/ClientApp/src/app/views/user/ChangeNicknameDialog.tsx
new file mode 100644
index 00000000..251b18c5
--- /dev/null
+++ b/Timeline/ClientApp/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/Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx b/Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx
new file mode 100644
index 00000000..1a111877
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx
@@ -0,0 +1,105 @@
+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 { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
+import { useAvatar } from "@/services/user";
+
+import BlobImage from "../common/BlobImage";
+import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
+
+export type PersonalTimelineManageItem = "avatar" | "nickname";
+
+export type UserInfoCardProps = TimelineCardComponentProps<
+ PersonalTimelineManageItem
+>;
+
+const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
+ const { onHeight, onManage } = props;
+ const { t } = useTranslation();
+
+ const avatar = useAvatar(props.timeline.owner.username);
+
+ // 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}
+ >
+ <BlobImage
+ blob={avatar}
+ 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/views/user/UserPageUI.tsx b/Timeline/ClientApp/src/app/views/user/UserPageUI.tsx
new file mode 100644
index 00000000..d405399c
--- /dev/null
+++ b/Timeline/ClientApp/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/Timeline/ClientApp/src/app/views/user/index.tsx b/Timeline/ClientApp/src/app/views/user/index.tsx
new file mode 100644
index 00000000..7c0b1563
--- /dev/null
+++ b/Timeline/ClientApp/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/Timeline/ClientApp/src/app/views/user/user.sass b/Timeline/ClientApp/src/app/views/user/user.sass
new file mode 100644
index 00000000..ca2d10f5
--- /dev/null
+++ b/Timeline/ClientApp/src/app/views/user/user.sass
@@ -0,0 +1,10 @@
+.login-container
+ max-width: 600px
+
+.change-avatar-cropper-row
+ max-height: 400px
+
+.change-avatar-img
+ min-width: 50%
+ max-width: 100%
+ max-height: 400px