diff options
Diffstat (limited to 'FrontEnd/src/app/views/timeline-common')
12 files changed, 1482 insertions, 0 deletions
diff --git a/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx b/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx new file mode 100644 index 00000000..3c52150f --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx @@ -0,0 +1,23 @@ +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/FrontEnd/src/app/views/timeline-common/InfoCardTemplate.tsx b/FrontEnd/src/app/views/timeline-common/InfoCardTemplate.tsx new file mode 100644 index 00000000..a8de20aa --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/InfoCardTemplate.tsx @@ -0,0 +1,26 @@ +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/FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx b/FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx new file mode 100644 index 00000000..e67cfb43 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx @@ -0,0 +1,58 @@ +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/FrontEnd/src/app/views/timeline-common/Timeline.tsx b/FrontEnd/src/app/views/timeline-common/Timeline.tsx new file mode 100644 index 00000000..fd051d45 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/Timeline.tsx @@ -0,0 +1,84 @@ +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/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx b/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx new file mode 100644 index 00000000..4db23371 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx @@ -0,0 +1,172 @@ +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/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx new file mode 100644 index 00000000..67a8543a --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx @@ -0,0 +1,211 @@ +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/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx new file mode 100644 index 00000000..d5c91622 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx @@ -0,0 +1,185 @@ +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/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx new file mode 100644 index 00000000..6c2c43c1 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx @@ -0,0 +1,243 @@ +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/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx new file mode 100644 index 00000000..dfa2f879 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx @@ -0,0 +1,241 @@ +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/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx new file mode 100644 index 00000000..87638f31 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx @@ -0,0 +1,72 @@ +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/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx b/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx new file mode 100644 index 00000000..93a2a32c --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx @@ -0,0 +1,21 @@ +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/FrontEnd/src/app/views/timeline-common/timeline-common.sass b/FrontEnd/src/app/views/timeline-common/timeline-common.sass new file mode 100644 index 00000000..4151bfcc --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/timeline-common.sass @@ -0,0 +1,146 @@ +@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 |