From a314b5350e269676e8c39eda4cc7842751b1a7fc Mon Sep 17 00:00:00 2001 From: crupest Date: Tue, 1 Sep 2020 02:32:06 +0800 Subject: ... --- .../timeline-common/TimelinePageTemplateUI.tsx | 325 +++++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx (limited to 'Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx') diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx new file mode 100644 index 00000000..7af11efa --- /dev/null +++ b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx @@ -0,0 +1,325 @@ +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 "@/services/alert"; +import { useEventEmiiter, UiLogicError } from "@/common"; +import { + TimelineInfo, + TimelinePostsWithSyncState, + timelineService, +} from "@/services/timeline"; +import { userService } from "@/services/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 ( +
+ {(() => { + switch (state) { + case "syncing": { + return ( + <> + + + {t("timeline.postSyncState.syncing")} + + + ); + } + case "synced": { + return ( + <> + + + {t("timeline.postSyncState.synced")} + + + ); + } + case "offline": { + return ( + <> + + + {t("timeline.postSyncState.offline")} + + + ); + } + default: + throw new UiLogicError("Unknown sync state."); + } + })()} +
+ ); +}; + +export interface TimelineCardComponentProps { + timeline: TimelineInfo; + onManage?: (item: TManageItems | "property") => void; + onMember: () => void; + className?: string; + onHeight?: (height: number) => void; +} + +export interface TimelinePageTemplateUIProps { + avatarKey?: string | number; + timeline?: TimelineInfo; + postListState?: TimelinePostsWithSyncState; + CardComponent: React.ComponentType>; + onMember: () => void; + onManage?: (item: TManageItems | "property") => void; + onPost?: TimelinePostSendCallback; + onDelete: TimelineDeleteCallback; + error?: string; +} + +export default function TimelinePageTemplateUI( + props: TimelinePageTemplateUIProps +): React.ReactElement | null { + const { timeline, postListState } = props; + + const { t } = useTranslation(); + + const bottomSpaceRef = React.useRef(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(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(0); + + const genCardCollapseLocalStorageKey = (uniqueId: string): string => + `timeline.${uniqueId}.cardCollapse`; + + const cardCollapseLocalStorageKey = + timeline != null ? genCardCollapseLocalStorageKey(timeline.uniqueId) : null; + + const [infoCardCollapse, setInfoCardCollapse] = React.useState(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 =

{t(props.error)}

; + } 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 = ( +

{t("timeline.messageCantSee")}

+ ); + } 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 = ( +
+ + +
+ ); + if (props.onPost != null) { + timelineBody = ( + <> + {timelineBody} +
+ + + ); + } + } + } else { + timelineBody = ( +
+ +
+ ); + } + const { CardComponent } = props; + + body = ( + <> +
+ { + 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" + /> + +
+ {timelineBody} + + ); + } else { + body = ( +
+ +
+ ); + } + } + + return ( + <> + +
+
+ {body} +
+ + ); +} -- cgit v1.2.3