aboutsummaryrefslogtreecommitdiff
path: root/Timeline/ClientApp/src/app/views/timeline-common
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2020-10-27 19:21:35 +0800
committercrupest <crupest@outlook.com>2020-10-27 19:21:35 +0800
commit05ccb4d8f1bbe3fb64e117136b4a89bcfb0b0b33 (patch)
tree929e514de85eb82a5acb96ecffc6e6d2d95f878f /Timeline/ClientApp/src/app/views/timeline-common
parent986c6f2e3b858d6332eba0b42acc6861cd4d0227 (diff)
downloadtimeline-05ccb4d8f1bbe3fb64e117136b4a89bcfb0b0b33.tar.gz
timeline-05ccb4d8f1bbe3fb64e117136b4a89bcfb0b0b33.tar.bz2
timeline-05ccb4d8f1bbe3fb64e117136b4a89bcfb0b0b33.zip
Split front and back end.
Diffstat (limited to 'Timeline/ClientApp/src/app/views/timeline-common')
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx23
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx26
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx58
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/Timeline.tsx84
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx172
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx211
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx185
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx243
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx241
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx72
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelineTop.tsx21
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass146
12 files changed, 0 insertions, 1482 deletions
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx b/Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx
deleted file mode 100644
index 3c52150f..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-import Svg from "react-inlinesvg";
-import arrowsAngleContractIcon from "bootstrap-icons/icons/arrows-angle-contract.svg";
-import arrowsAngleExpandIcon from "bootstrap-icons/icons/arrows-angle-expand.svg";
-
-const CollapseButton: React.FC<{
- collapse: boolean;
- onClick: () => void;
- className?: string;
- style?: React.CSSProperties;
-}> = ({ collapse, onClick, className, style }) => {
- return (
- <Svg
- src={collapse ? arrowsAngleExpandIcon : arrowsAngleContractIcon}
- onClick={onClick}
- className={clsx("text-primary icon-button", className)}
- style={style}
- />
- );
-};
-
-export default CollapseButton;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx b/Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx
deleted file mode 100644
index a8de20aa..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-
-import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
-import SyncStatusBadge from "../timeline-common/SyncStatusBadge";
-import CollapseButton from "../timeline-common/CollapseButton";
-
-const InfoCardTemplate: React.FC<
- Pick<
- TimelineCardComponentProps<"">,
- "collapse" | "toggleCollapse" | "syncStatus" | "className"
- > & { children: React.ReactElement[] }
-> = ({ collapse, toggleCollapse, syncStatus, className, children }) => {
- return (
- <div className={clsx("cru-card p-2 clearfix", className)}>
- <div className="float-right d-flex align-items-center">
- <SyncStatusBadge status={syncStatus} className="mr-2" />
- <CollapseButton collapse={collapse} onClick={toggleCollapse} />
- </div>
-
- <div style={{ display: collapse ? "none" : "block" }}>{children}</div>
- </div>
- );
-};
-
-export default InfoCardTemplate;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx b/Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx
deleted file mode 100644
index e67cfb43..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-import { useTranslation } from "react-i18next";
-
-import { UiLogicError } from "@/common";
-
-export type TimelineSyncStatus = "syncing" | "synced" | "offline";
-
-const SyncStatusBadge: React.FC<{
- status: TimelineSyncStatus;
- style?: React.CSSProperties;
- className?: string;
-}> = ({ status, style, className }) => {
- const { t } = useTranslation();
-
- return (
- <div style={style} className={clsx("timeline-sync-state-badge", className)}>
- {(() => {
- switch (status) {
- 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 default SyncStatusBadge;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/Timeline.tsx b/Timeline/ClientApp/src/app/views/timeline-common/Timeline.tsx
deleted file mode 100644
index fd051d45..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/Timeline.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-
-import { TimelinePostInfo } from "@/services/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("timeline", props.className)}>
- {(() => {
- 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/views/timeline-common/TimelineItem.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx
deleted file mode 100644
index 4db23371..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-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 { Modal, Button } from "react-bootstrap";
-
-import { useAvatar } from "@/services/user";
-import { TimelinePostInfo } from "@/services/timeline";
-
-import BlobImage from "../common/BlobImage";
-
-const TimelinePostDeleteConfirmDialog: React.FC<{
- toggle: () => void;
- onConfirm: () => void;
-}> = ({ toggle, onConfirm }) => {
- const { t } = useTranslation();
-
- return (
- <Modal toggle={toggle} isOpen centered>
- <Modal.Header>
- <Modal.Title className="text-danger">
- {t("timeline.post.deleteDialog.title")}
- </Modal.Title>
- </Modal.Header>
- <Modal.Body>{t("timeline.post.deleteDialog.prompt")}</Modal.Body>
- <Modal.Footer>
- <Button variant="secondary" onClick={toggle}>
- {t("operationDialog.cancel")}
- </Button>
- <Button
- variant="danger"
- onClick={() => {
- onConfirm();
- toggle();
- }}
- >
- {t("operationDialog.confirm")}
- </Button>
- </Modal.Footer>
- </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 (
- <div
- className={clsx(
- "timeline-item position-relative",
- current && "current",
- props.className
- )}
- onClick={props.onClick}
- style={props.style}
- >
- <div className="timeline-line-area-container">
- <div 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" />}
- </div>
- </div>
- <div className="timeline-content-area">
- <div>
- <span className="mr-2">
- <span className="text-primary white-space-no-wrap mr-2">
- {props.post.time.toLocaleString(i18n.languages)}
- </span>
- <small className="text-dark">{props.post.author.nickname}</small>
- </span>
- {more != null ? (
- <Svg
- src={chevronDownIcon}
- className="text-info icon-button"
- onClick={(e) => {
- more.toggle();
- e.stopPropagation();
- }}
- />
- ) : null}
- </div>
- <div className="timeline-content">
- <Link
- className="float-left m-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>
- </div>
- {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 icon-button large"
- onClick={(e) => {
- toggleDeleteDialog();
- e.stopPropagation();
- }}
- />
- </div>
- {deleteDialog ? (
- <TimelinePostDeleteConfirmDialog
- toggle={() => {
- toggleDeleteDialog();
- more.toggle();
- }}
- onConfirm={more.onDelete}
- />
- ) : null}
- </>
- ) : null}
- </div>
- );
-};
-
-export default TimelineItem;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx
deleted file mode 100644
index 67a8543a..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx
+++ /dev/null
@@ -1,211 +0,0 @@
-import React, { useState } from "react";
-import { useTranslation } from "react-i18next";
-import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap";
-
-import { User, useAvatar } from "@/services/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 (
- <ListGroup.Item className="container">
- <Row>
- <Col xs="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"
- variant="danger"
- onClick={() => {
- onRemove(user.username);
- }}
- >
- {t("timeline.member.remove")}
- </Button>
- );
- })()}
- </Row>
- </ListGroup.Item>
- );
-};
-
-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
- variant="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 show centered onHide={props.onClose}>
- <TimelineMember {...props} />
- </Modal>
- );
-};
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx
deleted file mode 100644
index d5c91622..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-import React from "react";
-import { useTranslation } from "react-i18next";
-import { of } from "rxjs";
-import { catchError } from "rxjs/operators";
-
-import { UiLogicError } from "@/common";
-import { pushAlert } from "@/services/alert";
-import { useUser, userInfoService, UserNotExistError } from "@/services/user";
-import {
- timelineService,
- usePostList,
- useTimelineInfo,
-} from "@/services/timeline";
-
-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<
- Omit<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]
- );
-
- 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={() => setDialog("member")}
- />
- {dialogElement}
- </>
- );
-}
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
deleted file mode 100644
index 6c2c43c1..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-import React from "react";
-import { useTranslation } from "react-i18next";
-import { fromEvent } from "rxjs";
-import { Spinner } from "react-bootstrap";
-
-import { getAlertHost } from "@/services/alert";
-import { useEventEmiiter, UiLogicError } from "@/common";
-import {
- TimelineInfo,
- TimelinePostsWithSyncState,
- timelineService,
-} from "@/services/timeline";
-import { userService } from "@/services/user";
-
-import Timeline, {
- TimelinePostInfoEx,
- TimelineDeleteCallback,
-} from "./Timeline";
-import TimelineTop from "./TimelineTop";
-import TimelinePostEdit, { TimelinePostSendCallback } from "./TimelinePostEdit";
-import { TimelineSyncStatus } from "./SyncStatusBadge";
-
-export interface TimelineCardComponentProps<TManageItems> {
- timeline: TimelineInfo;
- onManage?: (item: TManageItems | "property") => void;
- onMember: () => void;
- className?: string;
- collapse: boolean;
- syncStatus: TimelineSyncStatus;
- toggleCollapse: () => void;
-}
-
-export interface TimelinePageTemplateUIProps<TManageItems> {
- 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 genCardCollapseLocalStorageKey = (uniqueId: string): string =>
- `timeline.${uniqueId}.cardCollapse`;
-
- const cardCollapseLocalStorageKey =
- timeline != null ? genCardCollapseLocalStorageKey(timeline.uniqueId) : null;
-
- const [cardCollapse, setCardCollapse] = React.useState<boolean>(true);
- React.useEffect(() => {
- if (cardCollapseLocalStorageKey != null) {
- const savedCollapse =
- window.localStorage.getItem(cardCollapseLocalStorageKey) === "true";
- setCardCollapse(savedCollapse);
- }
- }, [cardCollapseLocalStorageKey]);
-
- const toggleCardCollapse = (): void => {
- const newState = !cardCollapse;
- setCardCollapse(newState);
- if (timeline != null) {
- window.localStorage.setItem(
- genCardCollapseLocalStorageKey(timeline.uniqueId),
- newState.toString()
- );
- }
- };
-
- 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
- ),
- })
- );
-
- timelineBody = (
- <Timeline
- containerRef={timelineRef}
- posts={posts}
- onDelete={props.onDelete}
- onResize={triggerResizeEvent}
- />
- );
- if (props.onPost != null) {
- timelineBody = (
- <>
- {timelineBody}
- <div ref={bottomSpaceRef} className="flex-fix-length" />
- <TimelinePostEdit
- className="fixed-bottom"
- onPost={props.onPost}
- onHeightChange={onPostEditHeightChange}
- timelineUniqueId={timeline.uniqueId}
- />
- </>
- );
- }
- }
- } else {
- timelineBody = (
- <div className="full-viewport-center-child">
- <Spinner variant="primary" animation="grow" />
- </div>
- );
- }
-
- const { CardComponent } = props;
- const syncStatus: TimelineSyncStatus =
- postListState == null || postListState.syncing
- ? "syncing"
- : postListState.type === "synced"
- ? "synced"
- : "offline";
-
- body = (
- <>
- <CardComponent
- className="timeline-template-card"
- timeline={timeline}
- onManage={props.onManage}
- onMember={props.onMember}
- syncStatus={syncStatus}
- collapse={cardCollapse}
- toggleCollapse={toggleCardCollapse}
- />
- <TimelineTop height="56px" />
- {timelineBody}
- </>
- );
- } else {
- body = (
- <div className="full-viewport-center-child">
- <Spinner variant="primary" animation="grow" />
- </div>
- );
- }
- }
-
- return body;
-}
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx
deleted file mode 100644
index dfa2f879..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx
+++ /dev/null
@@ -1,241 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-import { useTranslation } from "react-i18next";
-import Svg from "react-inlinesvg";
-import { Button, Spinner, Row, Col, Form } from "react-bootstrap";
-import textIcon from "bootstrap-icons/icons/card-text.svg";
-import imageIcon from "bootstrap-icons/icons/image.svg";
-
-import { UiLogicError } from "@/common";
-
-import { pushAlert } from "@/services/alert";
-import { TimelineCreatePostRequest } from "@/services/timeline";
-
-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 (
- <>
- <Form.File
- label={t("chooseImage")}
- onChange={onInputChange}
- accept="image/*"
- className="mx-3 my-1 d-inline-block"
- />
- {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={clsx("container-fluid bg-light", props.className)}
- >
- <Row>
- <Col className="px-1 py-1">
- {kind === "text" ? (
- <Form.Control
- as="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 xs="auto" className="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
- variant="primary"
- onClick={onSend}
- disabled={!canSend}
- >
- {t("timeline.send")}
- </Button>
- </>
- );
- } else {
- return <Spinner variant="primary" animation="border" />;
- }
- })()}
- </Col>
- </Row>
- </div>
- );
-};
-
-export default TimelinePostEdit;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
deleted file mode 100644
index 87638f31..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import React from "react";
-
-import {
- TimelineVisibility,
- kTimelineVisibilities,
- TimelineChangePropertyRequest,
-} from "@/services/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/views/timeline-common/TimelineTop.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelineTop.tsx
deleted file mode 100644
index 93a2a32c..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelineTop.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import React from "react";
-
-export interface TimelineTopProps {
- height?: number | string;
- children?: React.ReactElement;
-}
-
-const TimelineTop: React.FC<TimelineTopProps> = ({ height, children }) => {
- return (
- <div style={{ height: height }} className="timeline-top">
- <div className="timeline-line-area-container">
- <div className="timeline-line-area">
- <div className="timeline-line-segment"></div>
- </div>
- </div>
- {children}
- </div>
- );
-};
-
-export default TimelineTop;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass b/Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass
deleted file mode 100644
index 4151bfcc..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass
+++ /dev/null
@@ -1,146 +0,0 @@
-@use 'sass:color'
-
-.timeline
- z-index: 0
- position: relative
-
- &-item
- display: flex
-
-$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-container
- display: flex
- justify-content: flex-end
- padding-right: 5px
-
- flex: 0 0 auto
- width: 60px
-
- &-area
- display: flex
- flex-direction: column
- align-items: center
- width: 30px
-
- &-segment
- width: $timeline-line-width
- background: $timeline-line-color
-
- &.start
- height: 14px
- 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
-
-.timeline-top
- display: flex
- justify-content: space-between
-
- .timeline-line-segment
- flex: 1 1 auto
-
-.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-content-area
- padding: 10px 0
- flex-grow: 1
-
-.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
-
-.timeline-page-top-space
- transition: height 0.5s
-
-.timeline-sync-state-badge
- 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
-
-.timeline-template-card
- position: fixed
- z-index: 1
- top: 56px
- right: 0
- margin: 0.5em