diff options
| author | crupest <crupest@outlook.com> | 2020-09-01 02:32:06 +0800 | 
|---|---|---|
| committer | crupest <crupest@outlook.com> | 2020-09-01 02:32:06 +0800 | 
| commit | aa89b6cce7701a57b0c377d938788b4c940013d6 (patch) | |
| tree | ae95cb16698439ac825eb1d692ce14125b625ecb /Timeline/ClientApp/src/app/timeline | |
| parent | c3e95a6cd7322c644159eed6350a20dfd1a002ff (diff) | |
| download | timeline-aa89b6cce7701a57b0c377d938788b4c940013d6.tar.gz timeline-aa89b6cce7701a57b0c377d938788b4c940013d6.tar.bz2 timeline-aa89b6cce7701a57b0c377d938788b4c940013d6.zip  | |
...
Diffstat (limited to 'Timeline/ClientApp/src/app/timeline')
13 files changed, 0 insertions, 1692 deletions
diff --git a/Timeline/ClientApp/src/app/timeline/Timeline.tsx b/Timeline/ClientApp/src/app/timeline/Timeline.tsx deleted file mode 100644 index 780588d1..00000000 --- a/Timeline/ClientApp/src/app/timeline/Timeline.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React from "react"; -import clsx from "clsx"; - -import { TimelinePostInfo } from "../data/timeline"; - -import TimelineItem from "./TimelineItem"; - -export interface TimelinePostInfoEx extends TimelinePostInfo { -  deletable: boolean; -} - -export type TimelineDeleteCallback = (index: number, id: number) => void; - -export interface TimelineProps { -  className?: string; -  posts: TimelinePostInfoEx[]; -  onDelete: TimelineDeleteCallback; -  onResize?: () => void; -  containerRef?: React.Ref<HTMLDivElement>; -} - -const Timeline: React.FC<TimelineProps> = (props) => { -  const { posts, onDelete, onResize } = props; - -  const [indexShowDeleteButton, setIndexShowDeleteButton] = React.useState< -    number -  >(-1); - -  const onItemClick = React.useCallback(() => { -    setIndexShowDeleteButton(-1); -  }, []); - -  const onToggleDelete = React.useMemo(() => { -    return posts.map((post, i) => { -      return post.deletable -        ? () => { -            setIndexShowDeleteButton((oldIndexShowDeleteButton) => { -              return oldIndexShowDeleteButton !== i ? i : -1; -            }); -          } -        : undefined; -    }); -  }, [posts]); - -  const onItemDelete = React.useMemo(() => { -    return posts.map((post, i) => { -      return () => { -        onDelete(i, post.id); -      }; -    }); -  }, [posts, onDelete]); - -  return ( -    <div -      ref={props.containerRef} -      className={clsx("container-fluid timeline", props.className)} -    > -      <div className="timeline-enter-animation-mask" /> -      {(() => { -        const length = posts.length; -        return posts.map((post, i) => { -          const toggleMore = onToggleDelete[i]; - -          return ( -            <TimelineItem -              post={post} -              key={post.id} -              current={length - 1 === i} -              more={ -                toggleMore -                  ? { -                      isOpen: indexShowDeleteButton === i, -                      toggle: toggleMore, -                      onDelete: onItemDelete[i], -                    } -                  : undefined -              } -              onClick={onItemClick} -              onResize={onResize} -            /> -          ); -        }); -      })()} -    </div> -  ); -}; - -export default Timeline; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx deleted file mode 100644 index 5ebbf9df..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from "react"; -import { useHistory } from "react-router"; -import { Trans } from "react-i18next"; - -import OperationDialog from "../common/OperationDialog"; -import { timelineService } from "../data/timeline"; - -interface TimelineDeleteDialog { -  open: boolean; -  name: string; -  close: () => void; -} - -const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => { -  const history = useHistory(); - -  const { name } = props; - -  return ( -    <OperationDialog -      open={props.open} -      close={props.close} -      title="timeline.deleteDialog.title" -      titleColor="danger" -      inputPrompt={() => { -        return ( -          <Trans i18nKey="timeline.deleteDialog.inputPrompt"> -            0<code className="mx-2">{{ name }}</code>2 -          </Trans> -        ); -      }} -      inputScheme={[ -        { -          type: "text", -          validator: (value) => { -            if (value !== name) { -              return "timeline.deleteDialog.notMatch"; -            } else { -              return null; -            } -          }, -        }, -      ]} -      onProcess={() => { -        return timelineService.deleteTimeline(name).toPromise(); -      }} -      onSuccessAndClose={() => { -        history.replace("/"); -      }} -    /> -  ); -}; - -export default TimelineDeleteDialog; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx deleted file mode 100644 index c11c3376..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx +++ /dev/null @@ -1,110 +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 { useAvatar } from "../data/user"; -import { timelineVisibilityTooltipTranslationMap } from "../data/timeline"; -import BlobImage from "../common/BlobImage"; - -import { TimelineCardComponentProps } from "./TimelinePageTemplateUI"; - -export type OrdinaryTimelineManageItem = "delete"; - -export type TimelineInfoCardProps = TimelineCardComponentProps< -  OrdinaryTimelineManageItem ->; - -const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (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 p-2 bg-light", props.className)} -      onTransitionEnd={notifyHeight} -    > -      <h3 className="text-primary mx-3 d-inline-block align-middle"> -        {props.timeline.name} -      </h3> -      <div className="d-inline-block align-middle"> -        <BlobImage -          blob={avatar} -          onLoad={notifyHeight} -          className="avatar small rounded-circle" -        /> -        {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("property")}> -                {t("timeline.manageItem.property")} -              </DropdownItem> -              <DropdownItem onClick={props.onMember}> -                {t("timeline.manageItem.member")} -              </DropdownItem> -              <DropdownItem divider /> -              <DropdownItem -                className="text-danger" -                onClick={() => onManage("delete")} -              > -                {t("timeline.manageItem.delete")} -              </DropdownItem> -            </DropdownMenu> -          </Dropdown> -        ) : ( -          <Button color="primary" outline onClick={props.onMember}> -            {t("timeline.memberButton")} -          </Button> -        )} -      </div> -    </div> -  ); -}; - -export default TimelineInfoCard; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx deleted file mode 100644 index 33f0741e..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import React from "react"; -import clsx from "clsx"; -import { -  Row, -  Col, -  Modal, -  ModalHeader, -  ModalBody, -  ModalFooter, -  Button, -} from "reactstrap"; -import { Link } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import Svg from "react-inlinesvg"; -import chevronDownIcon from "bootstrap-icons/icons/chevron-down.svg"; -import trashIcon from "bootstrap-icons/icons/trash.svg"; - -import BlobImage from "../common/BlobImage"; -import { useAvatar } from "../data/user"; -import { TimelinePostInfo } from "../data/timeline"; - -const TimelinePostDeleteConfirmDialog: React.FC<{ -  toggle: () => void; -  onConfirm: () => void; -}> = ({ toggle, onConfirm }) => { -  const { t } = useTranslation(); - -  return ( -    <Modal toggle={toggle} isOpen centered> -      <ModalHeader className="text-danger"> -        {t("timeline.post.deleteDialog.title")} -      </ModalHeader> -      <ModalBody>{t("timeline.post.deleteDialog.prompt")}</ModalBody> -      <ModalFooter> -        <Button color="secondary" onClick={toggle}> -          {t("operationDialog.cancel")} -        </Button> -        <Button -          color="danger" -          onClick={() => { -            onConfirm(); -            toggle(); -          }} -        > -          {t("operationDialog.confirm")} -        </Button> -      </ModalFooter> -    </Modal> -  ); -}; - -export interface TimelineItemProps { -  post: TimelinePostInfo; -  current?: boolean; -  more?: { -    isOpen: boolean; -    toggle: () => void; -    onDelete: () => void; -  }; -  onClick?: () => void; -  onResize?: () => void; -  className?: string; -  style?: React.CSSProperties; -} - -const TimelineItem: React.FC<TimelineItemProps> = (props) => { -  const { i18n } = useTranslation(); - -  const current = props.current === true; - -  const { more, onResize } = props; - -  const avatar = useAvatar(props.post.author.username); - -  const [deleteDialog, setDeleteDialog] = React.useState<boolean>(false); -  const toggleDeleteDialog = React.useCallback( -    () => setDeleteDialog((old) => !old), -    [] -  ); - -  return ( -    <Row -      className={clsx( -        "position-relative flex-nowrap", -        current && "current", -        props.className -      )} -      onClick={props.onClick} -      style={props.style} -    > -      <Col className="timeline-line-area"> -        <div className="timeline-line-segment start"></div> -        <div className="timeline-line-node-container"> -          <div className="timeline-line-node"></div> -        </div> -        <div className="timeline-line-segment end"></div> -        {current && <div className="timeline-line-segment current-end" />} -      </Col> -      <Col className="timeline-pt-start"> -        <Row className="flex-nowrap"> -          <div className="col-auto flex-shrink-1 px-0"> -            <Row className="ml-n3 mr-0 align-items-center"> -              <span className="ml-3 text-primary white-space-no-wrap"> -                {props.post.time.toLocaleString(i18n.languages)} -              </span> -              <small className="text-dark ml-3"> -                {props.post.author.nickname} -              </small> -            </Row> -          </div> -          {more != null ? ( -            <div className="col-auto px-2 d-flex justify-content-center align-items-center"> -              <Svg -                src={chevronDownIcon} -                className="text-info icon-button" -                onClick={(e: Event) => { -                  more.toggle(); -                  e.stopPropagation(); -                }} -              /> -            </div> -          ) : null} -        </Row> -        <div className="row d-block timeline-content"> -          <Link -            className="float-right float-sm-left mx-2" -            to={"/users/" + props.post.author.username} -          > -            <BlobImage -              onLoad={onResize} -              blob={avatar} -              className="avatar rounded" -            /> -          </Link> -          {(() => { -            const { content } = props.post; -            if (content.type === "text") { -              return content.text; -            } else { -              return ( -                <BlobImage -                  onLoad={onResize} -                  blob={content.data} -                  className="timeline-content-image" -                /> -              ); -            } -          })()} -        </div> -      </Col> -      {more != null && more.isOpen ? ( -        <> -          <div -            className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center" -            onClick={more.toggle} -          > -            <Svg -              src={trashIcon} -              className="text-danger large-icon-button" -              onClick={(e: Event) => { -                toggleDeleteDialog(); -                e.stopPropagation(); -              }} -            /> -          </div> -          {deleteDialog ? ( -            <TimelinePostDeleteConfirmDialog -              toggle={() => { -                toggleDeleteDialog(); -                more.toggle(); -              }} -              onConfirm={more.onDelete} -            /> -          ) : null} -        </> -      ) : null} -    </Row> -  ); -}; - -export default TimelineItem; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx deleted file mode 100644 index f334c6e9..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { -  Container, -  ListGroup, -  ListGroupItem, -  Modal, -  Row, -  Col, -  Button, -} from "reactstrap"; - -import { User, useAvatar } from "../data/user"; -import SearchInput from "../common/SearchInput"; -import BlobImage from "../common/BlobImage"; - -const TimelineMemberItem: React.FC<{ -  user: User; -  owner: boolean; -  onRemove?: (username: string) => void; -}> = ({ user, owner, onRemove }) => { -  const { t } = useTranslation(); - -  const avatar = useAvatar(user.username); - -  return ( -    <ListGroupItem className="container"> -      <Row> -        <Col className="col-auto"> -          <BlobImage blob={avatar} className="avatar small" /> -        </Col> -        <Col> -          <Row>{user.nickname}</Row> -          <Row> -            <small>{"@" + user.username}</small> -          </Row> -        </Col> -        {(() => { -          if (owner) { -            return null; -          } -          if (onRemove == null) { -            return null; -          } -          return ( -            <Button -              className="align-self-center" -              color="danger" -              onClick={() => { -                onRemove(user.username); -              }} -            > -              {t("timeline.member.remove")} -            </Button> -          ); -        })()} -      </Row> -    </ListGroupItem> -  ); -}; - -export interface TimelineMemberCallbacks { -  onCheckUser: (username: string) => Promise<User | null>; -  onAddUser: (user: User) => Promise<void>; -  onRemoveUser: (username: string) => void; -} - -export interface TimelineMemberProps { -  members: User[]; -  edit: TimelineMemberCallbacks | null | undefined; -} - -const TimelineMember: React.FC<TimelineMemberProps> = (props) => { -  const { t } = useTranslation(); - -  const [userSearchText, setUserSearchText] = useState<string>(""); -  const [userSearchState, setUserSearchState] = useState< -    | { -        type: "user"; -        data: User; -      } -    | { type: "error"; data: string } -    | { type: "loading" } -    | { type: "init" } -  >({ type: "init" }); - -  const userSearchAvatar = useAvatar( -    userSearchState.type === "user" ? userSearchState.data.username : undefined -  ); - -  const members = props.members; - -  return ( -    <Container className="px-4"> -      <ListGroup className="my-3"> -        {members.map((member, index) => ( -          <TimelineMemberItem -            key={member.username} -            user={member} -            owner={index === 0} -            onRemove={props.edit?.onRemoveUser} -          /> -        ))} -      </ListGroup> -      {(() => { -        const edit = props.edit; -        if (edit != null) { -          return ( -            <> -              <SearchInput -                value={userSearchText} -                onChange={(v) => { -                  setUserSearchText(v); -                }} -                loading={userSearchState.type === "loading"} -                onButtonClick={() => { -                  if (userSearchText === "") { -                    setUserSearchState({ -                      type: "error", -                      data: "login.emptyUsername", -                    }); -                    return; -                  } - -                  setUserSearchState({ type: "loading" }); -                  edit.onCheckUser(userSearchText).then( -                    (u) => { -                      if (u == null) { -                        setUserSearchState({ -                          type: "error", -                          data: "timeline.userNotExist", -                        }); -                      } else { -                        setUserSearchState({ type: "user", data: u }); -                      } -                    }, -                    (e) => { -                      setUserSearchState({ -                        type: "error", -                        data: `${e as string}`, -                      }); -                    } -                  ); -                }} -              /> -              {(() => { -                if (userSearchState.type === "user") { -                  const u = userSearchState.data; -                  const addable = -                    members.findIndex((m) => m.username === u.username) === -1; -                  return ( -                    <> -                      {!addable ? ( -                        <p>{t("timeline.member.alreadyMember")}</p> -                      ) : null} -                      <Container className="mb-3"> -                        <Row> -                          <Col className="col-auto"> -                            <BlobImage -                              blob={userSearchAvatar} -                              className="avatar small" -                            /> -                          </Col> -                          <Col> -                            <Row>{u.nickname}</Row> -                            <Row> -                              <small>{"@" + u.username}</small> -                            </Row> -                          </Col> -                          <Button -                            color="primary" -                            className="align-self-center" -                            disabled={!addable} -                            onClick={() => { -                              void edit.onAddUser(u).then((_) => { -                                setUserSearchText(""); -                                setUserSearchState({ type: "init" }); -                              }); -                            }} -                          > -                            {t("timeline.member.add")} -                          </Button> -                        </Row> -                      </Container> -                    </> -                  ); -                } else if (userSearchState.type === "error") { -                  return ( -                    <p className="text-danger">{t(userSearchState.data)}</p> -                  ); -                } -              })()} -            </> -          ); -        } else { -          return null; -        } -      })()} -    </Container> -  ); -}; - -export default TimelineMember; - -export interface TimelineMemberDialogProps extends TimelineMemberProps { -  open: boolean; -  onClose: () => void; -} - -export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = ( -  props -) => { -  return ( -    <Modal isOpen={props.open} toggle={props.onClose}> -      <TimelineMember {...props} /> -    </Modal> -  ); -}; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx deleted file mode 100644 index 21d52db1..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react"; -import { useParams } from "react-router"; - -import TimelinePageTemplate from "../timeline/TimelinePageTemplate"; - -import TimelinePageUI from "./TimelinePageUI"; -import { OrdinaryTimelineManageItem } from "./TimelineInfoCard"; -import TimelineDeleteDialog from "./TimelineDeleteDialog"; - -const TimelinePage: React.FC = (_) => { -  const { name } = useParams<{ name: string }>(); - -  const [dialog, setDialog] = React.useState<OrdinaryTimelineManageItem | null>( -    null -  ); - -  let dialogElement: React.ReactElement | undefined; -  if (dialog === "delete") { -    dialogElement = ( -      <TimelineDeleteDialog open close={() => setDialog(null)} name={name} /> -    ); -  } - -  return ( -    <> -      <TimelinePageTemplate -        name={name} -        UiComponent={TimelinePageUI} -        onManage={(item) => setDialog(item)} -        notFoundI18nKey="timeline.timelineNotExist" -      /> -      {dialogElement} -    </> -  ); -}; - -export default TimelinePage; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx deleted file mode 100644 index 62470e63..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { of } from "rxjs"; -import { catchError } from "rxjs/operators"; - -import { ExcludeKey } from "../utilities/type"; -import { pushAlert } from "../common/alert-service"; -import { useUser, userInfoService, UserNotExistError } from "../data/user"; -import { -  timelineService, -  usePostList, -  useTimelineInfo, -} from "../data/timeline"; -import { UiLogicError } from "../common"; - -import { TimelineDeleteCallback } from "./Timeline"; -import { TimelineMemberDialog } from "./TimelineMember"; -import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; -import { TimelinePageTemplateUIProps } from "./TimelinePageTemplateUI"; -import { TimelinePostSendCallback } from "./TimelinePostEdit"; - -export interface TimelinePageTemplateProps<TManageItem> { -  name: string; -  onManage: (item: TManageItem) => void; -  UiComponent: React.ComponentType< -    ExcludeKey<TimelinePageTemplateUIProps<TManageItem>, "CardComponent"> -  >; -  notFoundI18nKey: string; -} - -export default function TimelinePageTemplate<TManageItem>( -  props: TimelinePageTemplateProps<TManageItem> -): React.ReactElement | null { -  const { t } = useTranslation(); - -  const { name } = props; - -  const service = timelineService; - -  const user = useUser(); - -  const [dialog, setDialog] = React.useState<null | "property" | "member">( -    null -  ); - -  const timelineState = useTimelineInfo(name); - -  const timeline = timelineState?.timeline; - -  const postListState = usePostList(name); - -  const error: string | undefined = (() => { -    if (timelineState != null) { -      const { type, timeline } = timelineState; -      if (type === "offline" && timeline == null) return "Network Error"; -      if (type === "synced" && timeline == null) -        return t(props.notFoundI18nKey); -    } -    return undefined; -  })(); - -  const closeDialog = React.useCallback((): void => { -    setDialog(null); -  }, []); - -  let dialogElement: React.ReactElement | undefined; - -  if (dialog === "property") { -    if (timeline == null) { -      throw new UiLogicError( -        "Timeline is null but attempt to open change property dialog." -      ); -    } - -    dialogElement = ( -      <TimelinePropertyChangeDialog -        open -        close={closeDialog} -        oldInfo={{ -          visibility: timeline.visibility, -          description: timeline.description, -        }} -        onProcess={(req) => { -          return service.changeTimelineProperty(name, req).toPromise().then(); -        }} -      /> -    ); -  } else if (dialog === "member") { -    if (timeline == null) { -      throw new UiLogicError( -        "Timeline is null but attempt to open change property dialog." -      ); -    } - -    dialogElement = ( -      <TimelineMemberDialog -        open -        onClose={closeDialog} -        members={[timeline.owner, ...timeline.members]} -        edit={ -          service.hasManagePermission(user, timeline) -            ? { -                onCheckUser: (u) => { -                  return userInfoService -                    .getUserInfo(u) -                    .pipe( -                      catchError((e) => { -                        if (e instanceof UserNotExistError) { -                          return of(null); -                        } else { -                          throw e; -                        } -                      }) -                    ) -                    .toPromise(); -                }, -                onAddUser: (u) => { -                  return service.addMember(name, u.username).toPromise().then(); -                }, -                onRemoveUser: (u) => { -                  service.removeMember(name, u); -                }, -              } -            : null -        } -      /> -    ); -  } - -  const { UiComponent } = props; - -  const onDelete: TimelineDeleteCallback = React.useCallback( -    (index, id) => { -      service.deletePost(name, id).subscribe(null, () => { -        pushAlert({ -          type: "danger", -          message: t("timeline.deletePostFailed"), -        }); -      }); -    }, -    [service, name, t] -  ); - -  const onPost: TimelinePostSendCallback = React.useCallback( -    (req) => { -      return service.createPost(name, req).toPromise().then(); -    }, -    [service, name] -  ); - -  const onManageProp = props.onManage; - -  const onManage = React.useCallback( -    (item: "property" | TManageItem) => { -      if (item === "property") { -        setDialog(item); -      } else { -        onManageProp(item); -      } -    }, -    [onManageProp] -  ); - -  const onMember = React.useCallback(() => { -    setDialog("member"); -  }, []); - -  return ( -    <> -      <UiComponent -        error={error} -        timeline={timeline ?? undefined} -        postListState={postListState} -        onDelete={onDelete} -        onPost={ -          timeline != null && service.hasPostPermission(user, timeline) -            ? onPost -            : undefined -        } -        onManage={ -          timeline != null && service.hasManagePermission(user, timeline) -            ? onManage -            : undefined -        } -        onMember={onMember} -      /> -      {dialogElement} -    </> -  ); -} diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx deleted file mode 100644 index e6514478..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx +++ /dev/null @@ -1,324 +0,0 @@ -import React, { CSSProperties } from "react"; -import { Spinner } from "reactstrap"; -import { useTranslation } from "react-i18next"; -import { fromEvent } from "rxjs"; -import Svg from "react-inlinesvg"; -import clsx from "clsx"; -import arrowsAngleContractIcon from "bootstrap-icons/icons/arrows-angle-contract.svg"; -import arrowsAngleExpandIcon from "bootstrap-icons/icons/arrows-angle-expand.svg"; - -import { getAlertHost } from "../common/alert-service"; -import { useEventEmiiter, UiLogicError } from "../common"; -import { -  TimelineInfo, -  TimelinePostsWithSyncState, -  timelineService, -} from "../data/timeline"; -import { userService } from "../data/user"; -import AppBar from "../common/AppBar"; - -import Timeline, { -  TimelinePostInfoEx, -  TimelineDeleteCallback, -} from "./Timeline"; -import TimelinePostEdit, { TimelinePostSendCallback } from "./TimelinePostEdit"; - -type TimelinePostSyncState = "syncing" | "synced" | "offline"; - -const TimelinePostSyncStateBadge: React.FC<{ -  state: TimelinePostSyncState; -  style?: CSSProperties; -  className?: string; -}> = ({ state, style, className }) => { -  const { t } = useTranslation(); - -  return ( -    <div style={style} className={clsx("timeline-sync-state-badge", className)}> -      {(() => { -        switch (state) { -          case "syncing": { -            return ( -              <> -                <span className="timeline-sync-state-badge-pin bg-warning" /> -                <span className="text-warning"> -                  {t("timeline.postSyncState.syncing")} -                </span> -              </> -            ); -          } -          case "synced": { -            return ( -              <> -                <span className="timeline-sync-state-badge-pin bg-success" /> -                <span className="text-success"> -                  {t("timeline.postSyncState.synced")} -                </span> -              </> -            ); -          } -          case "offline": { -            return ( -              <> -                <span className="timeline-sync-state-badge-pin bg-danger" /> -                <span className="text-danger"> -                  {t("timeline.postSyncState.offline")} -                </span> -              </> -            ); -          } -          default: -            throw new UiLogicError("Unknown sync state."); -        } -      })()} -    </div> -  ); -}; - -export interface TimelineCardComponentProps<TManageItems> { -  timeline: TimelineInfo; -  onManage?: (item: TManageItems | "property") => void; -  onMember: () => void; -  className?: string; -  onHeight?: (height: number) => void; -} - -export interface TimelinePageTemplateUIProps<TManageItems> { -  avatarKey?: string | number; -  timeline?: TimelineInfo; -  postListState?: TimelinePostsWithSyncState; -  CardComponent: React.ComponentType<TimelineCardComponentProps<TManageItems>>; -  onMember: () => void; -  onManage?: (item: TManageItems | "property") => void; -  onPost?: TimelinePostSendCallback; -  onDelete: TimelineDeleteCallback; -  error?: string; -} - -export default function TimelinePageTemplateUI<TManageItems>( -  props: TimelinePageTemplateUIProps<TManageItems> -): React.ReactElement | null { -  const { timeline, postListState } = props; - -  const { t } = useTranslation(); - -  const bottomSpaceRef = React.useRef<HTMLDivElement | null>(null); - -  const onPostEditHeightChange = React.useCallback((height: number): void => { -    const { current: bottomSpaceDiv } = bottomSpaceRef; -    if (bottomSpaceDiv != null) { -      bottomSpaceDiv.style.height = `${height}px`; -    } -    if (height === 0) { -      const alertHost = getAlertHost(); -      if (alertHost != null) { -        alertHost.style.removeProperty("margin-bottom"); -      } -    } else { -      const alertHost = getAlertHost(); -      if (alertHost != null) { -        alertHost.style.marginBottom = `${height}px`; -      } -    } -  }, []); - -  const timelineRef = React.useRef<HTMLDivElement | null>(null); - -  const [getResizeEvent, triggerResizeEvent] = useEventEmiiter(); - -  React.useEffect(() => { -    const { current: timelineElement } = timelineRef; -    if (timelineElement != null) { -      let loadingScrollToBottom = true; -      let pinBottom = false; - -      const isAtBottom = (): boolean => -        window.innerHeight + window.scrollY + 10 >= document.body.scrollHeight; - -      const disableLoadingScrollToBottom = (): void => { -        loadingScrollToBottom = false; -        if (isAtBottom()) pinBottom = true; -      }; - -      const checkAndScrollToBottom = (): void => { -        if (loadingScrollToBottom || pinBottom) { -          window.scrollTo(0, document.body.scrollHeight); -        } -      }; - -      const subscriptions = [ -        fromEvent(timelineElement, "wheel").subscribe( -          disableLoadingScrollToBottom -        ), -        fromEvent(timelineElement, "pointerdown").subscribe( -          disableLoadingScrollToBottom -        ), -        fromEvent(timelineElement, "keydown").subscribe( -          disableLoadingScrollToBottom -        ), -        fromEvent(window, "scroll").subscribe(() => { -          if (loadingScrollToBottom) return; - -          if (isAtBottom()) { -            pinBottom = true; -          } else { -            pinBottom = false; -          } -        }), -        fromEvent(window, "resize").subscribe(checkAndScrollToBottom), -        getResizeEvent().subscribe(checkAndScrollToBottom), -      ]; - -      return () => { -        subscriptions.forEach((s) => s.unsubscribe()); -      }; -    } -  }, [getResizeEvent, triggerResizeEvent, timeline, postListState]); - -  const [cardHeight, setCardHeight] = React.useState<number>(0); - -  const genCardCollapseLocalStorageKey = (uniqueId: string): string => -    `timeline.${uniqueId}.cardCollapse`; - -  const cardCollapseLocalStorageKey = -    timeline != null ? genCardCollapseLocalStorageKey(timeline.uniqueId) : null; - -  const [infoCardCollapse, setInfoCardCollapse] = React.useState<boolean>(true); -  React.useEffect(() => { -    if (cardCollapseLocalStorageKey != null) { -      const savedCollapse = -        window.localStorage.getItem(cardCollapseLocalStorageKey) === "true"; -      setInfoCardCollapse(savedCollapse); -    } -  }, [cardCollapseLocalStorageKey]); - -  let body: React.ReactElement; - -  if (props.error != null) { -    body = <p className="text-danger">{t(props.error)}</p>; -  } else { -    if (timeline != null) { -      let timelineBody: React.ReactElement; -      if (postListState != null) { -        if (postListState.type === "notexist") { -          throw new UiLogicError( -            "Timeline is not null but post list state is notexist." -          ); -        } -        if (postListState.type === "forbid") { -          timelineBody = ( -            <p className="text-danger">{t("timeline.messageCantSee")}</p> -          ); -        } else { -          const posts: TimelinePostInfoEx[] = postListState.posts.map( -            (post) => ({ -              ...post, -              deletable: timelineService.hasModifyPostPermission( -                userService.currentUser, -                timeline, -                post -              ), -            }) -          ); - -          const topHeight: string = infoCardCollapse -            ? "calc(68px + 1.5em)" -            : `${cardHeight + 60}px`; - -          const syncState: TimelinePostSyncState = postListState.syncing -            ? "syncing" -            : postListState.type === "synced" -            ? "synced" -            : "offline"; - -          timelineBody = ( -            <div> -              <TimelinePostSyncStateBadge -                style={{ top: topHeight }} -                state={syncState} -              /> -              <Timeline -                containerRef={timelineRef} -                posts={posts} -                onDelete={props.onDelete} -                onResize={triggerResizeEvent} -              /> -            </div> -          ); -          if (props.onPost != null) { -            timelineBody = ( -              <> -                {timelineBody} -                <div ref={bottomSpaceRef} className="flex-fix-length" /> -                <TimelinePostEdit -                  onPost={props.onPost} -                  onHeightChange={onPostEditHeightChange} -                  timelineUniqueId={timeline.uniqueId} -                /> -              </> -            ); -          } -        } -      } else { -        timelineBody = ( -          <div className="full-viewport-center-child"> -            <Spinner color="primary" type="grow" /> -          </div> -        ); -      } -      const { CardComponent } = props; - -      body = ( -        <> -          <div -            className="fixed-top mt-appbar info-card-container" -            data-collapse={infoCardCollapse ? "true" : "false"} -          > -            <Svg -              src={ -                infoCardCollapse -                  ? arrowsAngleExpandIcon -                  : arrowsAngleContractIcon -              } -              onClick={() => { -                const newState = !infoCardCollapse; -                setInfoCardCollapse(newState); -                window.localStorage.setItem( -                  genCardCollapseLocalStorageKey(timeline.uniqueId), -                  newState.toString() -                ); -              }} -              className="float-right m-1 info-card-collapse-button text-primary icon-button" -            /> -            <CardComponent -              timeline={timeline} -              onManage={props.onManage} -              onMember={props.onMember} -              onHeight={setCardHeight} -              className="info-card-content" -            /> -          </div> -          {timelineBody} -        </> -      ); -    } else { -      body = ( -        <div className="full-viewport-center-child"> -          <Spinner color="primary" type="grow" /> -        </div> -      ); -    } -  } - -  return ( -    <> -      <AppBar /> -      <div> -        <div -          style={{ height: 56 + cardHeight }} -          className="timeline-page-top-space flex-fix-length" -        /> -        {body} -      </div> -    </> -  ); -} diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx deleted file mode 100644 index 63751eeb..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; - -import { ExcludeKey } from "../utilities/type"; - -import TimelinePageTemplateUI, { -  TimelinePageTemplateUIProps, -} from "./TimelinePageTemplateUI"; -import TimelineInfoCard, { -  OrdinaryTimelineManageItem, -} from "./TimelineInfoCard"; - -export type TimelinePageUIProps = ExcludeKey< -  TimelinePageTemplateUIProps<OrdinaryTimelineManageItem>, -  "CardComponent" ->; - -const TimelinePageUI: React.FC<TimelinePageUIProps> = (props) => { -  return <TimelinePageTemplateUI {...props} CardComponent={TimelineInfoCard} />; -}; - -export default TimelinePageUI; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx deleted file mode 100644 index b30dc8d3..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import React from "react"; -import { Button, Spinner, Row, Col } from "reactstrap"; -import { useTranslation } from "react-i18next"; -import Svg from "react-inlinesvg"; -import textIcon from "bootstrap-icons/icons/card-text.svg"; -import imageIcon from "bootstrap-icons/icons/image.svg"; - -import { pushAlert } from "../common/alert-service"; -import { TimelineCreatePostRequest } from "../data/timeline"; -import FileInput from "../common/FileInput"; -import { UiLogicError } from "../common"; - -interface TimelinePostEditImageProps { -  onSelect: (blob: Blob | null) => void; -} - -const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => { -  const { onSelect } = props; -  const { t } = useTranslation(); - -  const [file, setFile] = React.useState<File | null>(null); -  const [fileUrl, setFileUrl] = React.useState<string | null>(null); -  const [error, setError] = React.useState<string | null>(null); - -  React.useEffect(() => { -    if (file != null) { -      const url = URL.createObjectURL(file); -      setFileUrl(url); -      return () => { -        URL.revokeObjectURL(url); -      }; -    } -  }, [file]); - -  const onInputChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback( -    (e) => { -      const files = e.target.files; -      if (files == null || files.length === 0) { -        setFile(null); -        setFileUrl(null); -      } else { -        setFile(files[0]); -      } -      onSelect(null); -      setError(null); -    }, -    [onSelect] -  ); - -  const onImgLoad = React.useCallback(() => { -    onSelect(file); -  }, [onSelect, file]); - -  const onImgError = React.useCallback(() => { -    setError("loadImageError"); -  }, []); - -  return ( -    <> -      <FileInput -        labelText={t("chooseImage")} -        onChange={onInputChange} -        accept="image/*" -        className="mx-3 my-1" -      /> -      {fileUrl && error == null && ( -        <img -          src={fileUrl} -          className="timeline-post-edit-image" -          onLoad={onImgLoad} -          onError={onImgError} -        /> -      )} -      {error != null && <div className="text-danger">{t(error)}</div>} -    </> -  ); -}; - -export type TimelinePostSendCallback = ( -  content: TimelineCreatePostRequest -) => Promise<void>; - -export interface TimelinePostEditProps { -  className?: string; -  onPost: TimelinePostSendCallback; -  onHeightChange?: (height: number) => void; -  timelineUniqueId: string; -} - -const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { -  const { onPost } = props; - -  const { t } = useTranslation(); - -  const [state, setState] = React.useState<"input" | "process">("input"); -  const [kind, setKind] = React.useState<"text" | "image">("text"); -  const [text, setText] = React.useState<string>(""); -  const [imageBlob, setImageBlob] = React.useState<Blob | null>(null); - -  const draftLocalStorageKey = `timeline.${props.timelineUniqueId}.postDraft`; - -  React.useEffect(() => { -    setText(window.localStorage.getItem(draftLocalStorageKey) ?? ""); -  }, [draftLocalStorageKey]); - -  const canSend = kind === "text" || (kind === "image" && imageBlob != null); - -  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -  const containerRef = React.useRef<HTMLDivElement>(null!); - -  const notifyHeightChange = (): void => { -    if (props.onHeightChange) { -      props.onHeightChange(containerRef.current.clientHeight); -    } -  }; - -  React.useEffect(() => { -    if (props.onHeightChange) { -      props.onHeightChange(containerRef.current.clientHeight); -    } -    return () => { -      if (props.onHeightChange) { -        props.onHeightChange(0); -      } -    }; -  }); - -  const toggleKind = React.useCallback(() => { -    setKind((oldKind) => (oldKind === "text" ? "image" : "text")); -    setImageBlob(null); -  }, []); - -  const onSend = React.useCallback(() => { -    setState("process"); - -    const req: TimelineCreatePostRequest = (() => { -      switch (kind) { -        case "text": -          return { -            content: { -              type: "text", -              text: text, -            }, -          } as TimelineCreatePostRequest; -        case "image": -          if (imageBlob == null) { -            throw new UiLogicError( -              "Content type is image but image blob is null." -            ); -          } -          return { -            content: { -              type: "image", -              data: imageBlob, -            }, -          } as TimelineCreatePostRequest; -        default: -          throw new UiLogicError("Unknown content type."); -      } -    })(); - -    onPost(req).then( -      (_) => { -        if (kind === "text") { -          setText(""); -          window.localStorage.removeItem(draftLocalStorageKey); -        } -        setState("input"); -        setKind("text"); -      }, -      (_) => { -        pushAlert({ -          type: "danger", -          message: t("timeline.sendPostFailed"), -        }); -        setState("input"); -      } -    ); -  }, [onPost, kind, text, imageBlob, t, draftLocalStorageKey]); - -  const onImageSelect = React.useCallback((blob: Blob | null) => { -    setImageBlob(blob); -  }, []); - -  return ( -    <div ref={containerRef} className="container-fluid fixed-bottom bg-light"> -      <Row> -        <Col className="px-1 py-1"> -          {kind === "text" ? ( -            <textarea -              className="w-100 h-100 timeline-post-edit" -              value={text} -              disabled={state === "process"} -              onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => { -                const value = event.currentTarget.value; -                setText(value); -                window.localStorage.setItem(draftLocalStorageKey, value); -              }} -            /> -          ) : ( -            <TimelinePostEditImage onSelect={onImageSelect} /> -          )} -        </Col> -        <Col sm="col-auto align-self-end m-1"> -          {(() => { -            if (state === "input") { -              return ( -                <> -                  <div className="d-block text-center mt-1 mb-2"> -                    <Svg -                      onLoad={notifyHeightChange} -                      src={kind === "text" ? imageIcon : textIcon} -                      className="icon-button" -                      onClick={toggleKind} -                    /> -                  </div> -                  <Button color="primary" onClick={onSend} disabled={!canSend}> -                    {t("timeline.send")} -                  </Button> -                </> -              ); -            } else { -              return <Spinner />; -            } -          })()} -        </Col> -      </Row> -    </div> -  ); -}; - -export default TimelinePostEdit; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx deleted file mode 100644 index bb0e3ea2..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from "react"; - -import { -  TimelineVisibility, -  kTimelineVisibilities, -  TimelineChangePropertyRequest, -} from "../data/timeline"; -import OperationDialog, { -  OperationSelectInputInfoOption, -} from "../common/OperationDialog"; - -export interface TimelinePropertyInfo { -  visibility: TimelineVisibility; -  description: string; -} - -export interface TimelinePropertyChangeDialogProps { -  open: boolean; -  close: () => void; -  oldInfo: TimelinePropertyInfo; -  onProcess: (request: TimelineChangePropertyRequest) => Promise<void>; -} - -const labelMap: { [key in TimelineVisibility]: string } = { -  Private: "timeline.visibility.private", -  Public: "timeline.visibility.public", -  Register: "timeline.visibility.register", -}; - -const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> = ( -  props -) => { -  return ( -    <OperationDialog -      title={"timeline.dialogChangeProperty.title"} -      titleColor="default" -      inputScheme={[ -        { -          type: "select", -          label: "timeline.dialogChangeProperty.visibility", -          options: kTimelineVisibilities.map<OperationSelectInputInfoOption>( -            (v) => ({ -              label: labelMap[v], -              value: v, -            }) -          ), -          initValue: props.oldInfo.visibility, -        }, -        { -          type: "text", -          label: "timeline.dialogChangeProperty.description", -          initValue: props.oldInfo.description, -        }, -      ]} -      open={props.open} -      close={props.close} -      onProcess={([newVisibility, newDescription]) => { -        const req: TimelineChangePropertyRequest = {}; -        if (newVisibility !== props.oldInfo.visibility) { -          req.visibility = newVisibility as TimelineVisibility; -        } -        if (newDescription !== props.oldInfo.description) { -          req.description = newDescription as string; -        } -        return props.onProcess(req); -      }} -    /> -  ); -}; - -export default TimelinePropertyChangeDialog; diff --git a/Timeline/ClientApp/src/app/timeline/timeline-ui.sass b/Timeline/ClientApp/src/app/timeline/timeline-ui.sass deleted file mode 100644 index 79be64d3..00000000 --- a/Timeline/ClientApp/src/app/timeline/timeline-ui.sass +++ /dev/null @@ -1,35 +0,0 @@ -.info-card-container -  .info-card-collapse-button -    z-index: 1 -    position: relative - -  .info-card-content -    width: 100% -    position: absolute -    transform-origin: right top -    transition: transform 0.5s - -  &[data-collapse='true'] -    .info-card-content -      transform: scale(0) - -.timeline-page-top-space -  transition: height 0.5s - -.timeline-sync-state-badge -  position: fixed -  top: 0 -  right: 0 -  z-index: 1 -  font-size: 0.8em -  padding: 3px 8px -  border-radius: 5px -  background: #e8fbff - -.timeline-sync-state-badge-pin -  display: inline-block -  width: 0.4em -  height: 0.4em -  border-radius: 50% -  vertical-align: middle -  margin-right: 0.6em diff --git a/Timeline/ClientApp/src/app/timeline/timeline.sass b/Timeline/ClientApp/src/app/timeline/timeline.sass deleted file mode 100644 index d431a4c6..00000000 --- a/Timeline/ClientApp/src/app/timeline/timeline.sass +++ /dev/null @@ -1,131 +0,0 @@ -@use 'sass:color' - -.timeline -  display: flex -  flex-direction: column -  z-index: 0 -  position: relative - -@keyframes timeline-enter-animation-mask-animation -  to -    height: 0 - -.timeline-enter-animation-mask -  position: absolute -  left: 0 -  top: 0 -  height: calc(100% + 300px) -  width: 100% -  background: linear-gradient(to top, #ffffff00 0, 200px, white 300px, white) -  z-index: 100 -  animation: timeline-enter-animation-mask-animation 5s 0.3s forwards // Give it 0.3s to load, which I think is reasonable - -$timeline-line-width: 7px -$timeline-line-node-radius: 18px -$timeline-line-color: $primary -$timeline-line-color-current: #36c2e6 - -@keyframes timeline-line-node-noncurrent -  from -    background: $timeline-line-color - -  to -    background: color.adjust($timeline-line-color, $lightness: +10%) -    box-shadow: 0 0 20px 3px color.adjust($timeline-line-color, $lightness: +10%, $alpha: -0.1) - - -@keyframes timeline-line-node-current -  from -    background: $timeline-line-color-current - -  to -    background: color.adjust($timeline-line-color-current, $lightness: +10%) -    box-shadow: 0 0 20px 3px color.adjust($timeline-line-color-current, $lightness: +10%, $alpha: -0.1) - -.timeline-line -  &-area -    display: flex -    flex-direction: column -    align-items: center -    flex: 0 0 auto -    width: 60px - -  &-segment -    width: $timeline-line-width -    background: $timeline-line-color - -    &.start -      height: 20px -      flex: 0 0 auto - -    &.end -      flex: 1 1 auto - -    &.current-end -      height: 20px -      flex: 0 0 auto -      background: linear-gradient($timeline-line-color-current, transparent) - -  &-node-container -    flex: 0 0 auto -    position: relative -    width: $timeline-line-node-radius -    height: $timeline-line-node-radius - -  &-node -    width: $timeline-line-node-radius + 2 -    height: $timeline-line-node-radius + 2 -    position: absolute -    left: -1px -    top: -1px -    border-radius: 50% -    box-sizing: border-box -    z-index: 1 -    animation: 1s infinite alternate -    animation-name: timeline-line-node-noncurrent - - -.current -  .timeline-line -    &-segment - -      &.start -        background: linear-gradient($timeline-line-color, $timeline-line-color-current) - -      &.end -        background: $timeline-line-color-current - -    &-node -      animation-name: timeline-line-node-current - -.timeline-pt-start -  padding-top: 18px - -.timeline-item-delete-button -  position: absolute -  right: 0 -  bottom: 0 - -.timeline-content -  white-space: pre-line - -.timeline-content-image -  max-width: 60% -  max-height: 200px - - -.timeline-post-edit-image -  max-width: 100px -  max-height: 100px - -.mask -  background: change-color($color: white, $alpha: 0.8) -  z-index: 100 - -textarea.timeline-post-edit -  @extend .border-primary -  @extend .rounded - -  &:focus -    outline: none -    box-shadow: 0 0 5px 0 $primary  | 
