From f5d10683a1edeba4dabe148ff7aa682c044f7496 Mon Sep 17 00:00:00 2001 From: crupest Date: Sun, 26 Jul 2020 15:02:55 +0800 Subject: Merge front end repo --- Timeline/ClientApp/src/app/timeline/Timeline.tsx | 91 +++++++ .../src/app/timeline/TimelineDeleteDialog.tsx | 54 ++++ .../src/app/timeline/TimelineInfoCard.tsx | 109 ++++++++ .../ClientApp/src/app/timeline/TimelineItem.tsx | 183 +++++++++++++ .../ClientApp/src/app/timeline/TimelineMember.tsx | 218 ++++++++++++++++ .../ClientApp/src/app/timeline/TimelinePage.tsx | 36 +++ .../src/app/timeline/TimelinePageTemplate.tsx | 288 +++++++++++++++++++++ .../src/app/timeline/TimelinePageTemplateUI.tsx | 236 +++++++++++++++++ .../ClientApp/src/app/timeline/TimelinePageUI.tsx | 21 ++ .../src/app/timeline/TimelinePostEdit.tsx | 234 +++++++++++++++++ .../app/timeline/TimelinePropertyChangeDialog.tsx | 72 ++++++ .../ClientApp/src/app/timeline/timeline-ui.sass | 18 ++ Timeline/ClientApp/src/app/timeline/timeline.sass | 125 +++++++++ 13 files changed, 1685 insertions(+) create mode 100644 Timeline/ClientApp/src/app/timeline/Timeline.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelineItem.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelineMember.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePage.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx create mode 100644 Timeline/ClientApp/src/app/timeline/timeline-ui.sass create mode 100644 Timeline/ClientApp/src/app/timeline/timeline.sass (limited to 'Timeline/ClientApp/src/app/timeline') diff --git a/Timeline/ClientApp/src/app/timeline/Timeline.tsx b/Timeline/ClientApp/src/app/timeline/Timeline.tsx new file mode 100644 index 00000000..849933cf --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/Timeline.tsx @@ -0,0 +1,91 @@ +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; +} + +const Timeline: React.FC = (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 ( +
+
+ {(() => { + const length = posts.length; + return posts.map((post, i) => { + const toggleMore = onToggleDelete[i]; + + return ( + + ); + }); + })()} +
+ ); +}; + +export default Timeline; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx new file mode 100644 index 00000000..7bcea6c5 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx @@ -0,0 +1,54 @@ +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 = (props) => { + const history = useHistory(); + + const { name } = props; + + return ( + { + return ( + + 0{{ name }}2 + + ); + }} + 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 new file mode 100644 index 00000000..c25b2376 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx @@ -0,0 +1,109 @@ +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 { useAvatarUrl } from '../data/user'; +import { timelineVisibilityTooltipTranslationMap } from '../data/timeline'; + +import { TimelineCardComponentProps } from './TimelinePageTemplateUI'; + +export type OrdinaryTimelineManageItem = 'delete'; + +export type TimelineInfoCardProps = TimelineCardComponentProps< + OrdinaryTimelineManageItem +>; + +const TimelineInfoCard: React.FC = (props) => { + const { onHeight, onManage } = props; + + const { t } = useTranslation(); + + const avatarUrl = useAvatarUrl(props.timeline.owner.username); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const containerRef = React.useRef(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( + false + ); + const toggleManageDropdown = React.useCallback( + (): void => setManageDropdownOpen((old) => !old), + [] + ); + + return ( +
+

+ {props.timeline.name} +

+
+ + {props.timeline.owner.nickname} + + @{props.timeline.owner.username} + +
+

{props.timeline.description}

+ + {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])} + +
+ {onManage != null ? ( + + + {t('timeline.manage')} + + + onManage('property')}> + {t('timeline.manageItem.property')} + + + {t('timeline.manageItem.member')} + + + onManage('delete')} + > + {t('timeline.manageItem.delete')} + + + + ) : ( + + )} +
+
+ ); +}; + +export default TimelineInfoCard; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx new file mode 100644 index 00000000..11ac9f08 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx @@ -0,0 +1,183 @@ +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 { useAvatarUrl } from '../data/user'; +import { TimelinePostInfo, usePostDataUrl } from '../data/timeline'; + +const TimelinePostDeleteConfirmDialog: React.FC<{ + toggle: () => void; + onConfirm: () => void; +}> = ({ toggle, onConfirm }) => { + const { t } = useTranslation(); + + return ( + + + {t('timeline.post.deleteDialog.title')} + + {t('timeline.post.deleteDialog.prompt')} + + + + + + ); +}; + +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 = (props) => { + const { i18n } = useTranslation(); + + const current = props.current === true; + + const { more, onResize } = props; + + const avatarUrl = useAvatarUrl(props.post.author.username); + + const dataUrl = usePostDataUrl( + props.post.content.type === 'image', + props.post.timelineName, + props.post.id + ); + + const [deleteDialog, setDeleteDialog] = React.useState(false); + const toggleDeleteDialog = React.useCallback( + () => setDeleteDialog((old) => !old), + [] + ); + + return ( + + +
+
+
+
+
+ {current &&
} + + + +
+ + + {props.post.time.toLocaleString(i18n.languages)} + + + {props.post.author.nickname} + + +
+ {more != null ? ( +
+ { + more.toggle(); + e.stopPropagation(); + }} + /> +
+ ) : null} +
+
+ + + + {(() => { + const { content } = props.post; + if (content.type === 'text') { + return content.text; + } else { + return ( + + ); + } + })()} +
+ + {more != null && more.isOpen ? ( + <> +
+ { + toggleDeleteDialog(); + e.stopPropagation(); + }} + /> +
+ {deleteDialog ? ( + { + toggleDeleteDialog(); + more.toggle(); + }} + onConfirm={more.onDelete} + /> + ) : null} + + ) : null} + + ); +}; + +export default TimelineItem; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx new file mode 100644 index 00000000..8c637f46 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx @@ -0,0 +1,218 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Container, + ListGroup, + ListGroupItem, + Modal, + Row, + Col, + Button, +} from 'reactstrap'; + +import { User, useAvatarUrl } from '../data/user'; + +import SearchInput from '../common/SearchInput'; + +const TimelineMemberItem: React.FC<{ + user: User; + owner: boolean; + onRemove?: (username: string) => void; +}> = ({ user, owner, onRemove }) => { + const { t } = useTranslation(); + + const avatarUrl = useAvatarUrl(user.username); + + return ( + + + + + + + {user.nickname} + + {'@' + user.username} + + + {(() => { + if (owner) { + return null; + } + if (onRemove == null) { + return null; + } + return ( + + ); + })()} + + + ); +}; + +export interface TimelineMemberCallbacks { + onCheckUser: (username: string) => Promise; + onAddUser: (user: User) => Promise; + onRemoveUser: (username: string) => void; +} + +export interface TimelineMemberProps { + members: User[]; + edit: TimelineMemberCallbacks | null | undefined; +} + +const TimelineMember: React.FC = (props) => { + const { t } = useTranslation(); + + const [userSearchText, setUserSearchText] = useState(''); + const [userSearchState, setUserSearchState] = useState< + | { + type: 'user'; + data: User; + } + | { type: 'error'; data: string } + | { type: 'loading' } + | { type: 'init' } + >({ type: 'init' }); + + const userSearchAvatarUrl = useAvatarUrl( + userSearchState.type === 'user' ? userSearchState.data.username : undefined + ); + + const members = props.members; + + return ( + + + {members.map((member, index) => ( + + ))} + + {(() => { + const edit = props.edit; + if (edit != null) { + return ( + <> + { + 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 ? ( +

{t('timeline.member.alreadyMember')}

+ ) : null} + + + + + + + {u.nickname} + + {'@' + u.username} + + + + + + + ); + } else if (userSearchState.type === 'error') { + return ( +

{t(userSearchState.data)}

+ ); + } + })()} + + ); + } else { + return null; + } + })()} +
+ ); +}; + +export default TimelineMember; + +export interface TimelineMemberDialogProps extends TimelineMemberProps { + open: boolean; + onClose: () => void; +} + +export const TimelineMemberDialog: React.FC = ( + props +) => { + return ( + + + + ); +}; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx new file mode 100644 index 00000000..7d0a8807 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useParams } from 'react-router'; + +import TimelinePageUI from './TimelinePageUI'; +import TimelinePageTemplate from '../timeline/TimelinePageTemplate'; +import { OrdinaryTimelineManageItem } from './TimelineInfoCard'; +import TimelineDeleteDialog from './TimelineDeleteDialog'; + +const TimelinePage: React.FC = (_) => { + const { name } = useParams<{ name: string }>(); + + const [dialog, setDialog] = React.useState( + null + ); + + let dialogElement: React.ReactElement | undefined; + if (dialog === 'delete') { + dialogElement = ( + setDialog(null)} name={name} /> + ); + } + + return ( + <> + 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 new file mode 100644 index 00000000..9be7f305 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx @@ -0,0 +1,288 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { concat, without } from 'lodash'; +import { of } from 'rxjs'; +import { catchError, switchMap, map } from 'rxjs/operators'; + +import { ExcludeKey } from '../utilities/type'; +import { pushAlert } from '../common/alert-service'; +import { useUser, userInfoService, UserNotExistError } from '../data/user'; +import { + timelineService, + TimelineInfo, + TimelineNotExistError, +} from '../data/timeline'; + +import { TimelinePostInfoEx, TimelineDeleteCallback } from './Timeline'; +import { TimelineMemberDialog } from './TimelineMember'; +import TimelinePropertyChangeDialog from './TimelinePropertyChangeDialog'; +import { TimelinePageTemplateUIProps } from './TimelinePageTemplateUI'; +import { TimelinePostSendCallback } from './TimelinePostEdit'; +import { UiLogicError } from '../common'; + +export interface TimelinePageTemplateProps< + TManageItem, + TTimeline extends TimelineInfo +> { + name: string; + onManage: (item: TManageItem) => void; + UiComponent: React.ComponentType< + ExcludeKey, 'CardComponent'> + >; + dataVersion?: number; + notFoundI18nKey: string; +} + +export default function TimelinePageTemplate< + TManageItem, + TTimeline extends TimelineInfo +>( + props: TimelinePageTemplateProps +): React.ReactElement | null { + const { t } = useTranslation(); + + const { name } = props; + + const service = timelineService; + + const user = useUser(); + + const [dialog, setDialog] = React.useState( + null + ); + const [timeline, setTimeline] = React.useState( + undefined + ); + const [posts, setPosts] = React.useState< + TimelinePostInfoEx[] | 'forbid' | undefined + >(undefined); + const [error, setError] = React.useState(undefined); + + React.useEffect(() => { + const subscription = service + .getTimeline(name) + .pipe( + switchMap((ti) => { + setTimeline(ti); + if (!service.hasReadPermission(user, ti)) { + setPosts('forbid'); + return of(null); + } else { + return service + .getPosts(name) + .pipe(map((ps) => ({ timeline: ti, posts: ps }))); + } + }) + ) + .subscribe( + (data) => { + if (data != null) { + setPosts( + data.posts.map((post) => ({ + ...post, + deletable: service.hasModifyPostPermission( + user, + data.timeline, + post + ), + })) + ); + } + }, + (error) => { + if (error instanceof TimelineNotExistError) { + setError(t(props.notFoundI18nKey)); + } else { + setError( + // TODO: Convert this to a function. + (error as { message?: string })?.message ?? 'Unknown error' + ); + } + } + ); + return () => { + subscription.unsubscribe(); + }; + }, [name, service, user, t, props.dataVersion, props.notFoundI18nKey]); + + 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 = ( + { + return service + .changeTimelineProperty(name, req) + .pipe( + map((newTimeline) => { + setTimeline(newTimeline); + }) + ) + .toPromise(); + }} + /> + ); + } else if (dialog === 'member') { + if (timeline == null) { + throw new UiLogicError( + 'Timeline is null but attempt to open change property dialog.' + ); + } + + dialogElement = ( + { + 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) + .pipe( + map(() => { + setTimeline({ + ...timeline, + members: concat(timeline.members, u), + }); + }) + ) + .toPromise(); + }, + onRemoveUser: (u) => { + service.removeMember(name, u).subscribe(() => { + const toDelete = timeline.members.find( + (m) => m.username === u + ); + if (toDelete == null) { + throw new UiLogicError( + 'The member to delete is not in list.' + ); + } + setTimeline({ + ...timeline, + members: without(timeline.members, toDelete), + }); + }); + }, + } + : null + } + /> + ); + } + + const { UiComponent } = props; + + const onDelete: TimelineDeleteCallback = React.useCallback( + (index, id) => { + service.deletePost(name, id).subscribe( + () => { + setPosts((oldPosts) => + without( + oldPosts as TimelinePostInfoEx[], + (oldPosts as TimelinePostInfoEx[])[index] + ) + ); + }, + () => { + pushAlert({ + type: 'danger', + message: t('timeline.deletePostFailed'), + }); + } + ); + }, + [service, name, t] + ); + + const onPost: TimelinePostSendCallback = React.useCallback( + (req) => { + return service + .createPost(name, req) + .pipe( + map((newPost) => { + setPosts((oldPosts) => + concat(oldPosts as TimelinePostInfoEx[], { + ...newPost, + deletable: true, + }) + ); + }) + ) + .toPromise(); + }, + [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 ( + <> + + {dialogElement} + + ); +} diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx new file mode 100644 index 00000000..4b3b3096 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx @@ -0,0 +1,236 @@ +import React from 'react'; +import { Spinner } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; +import { fromEvent } from 'rxjs'; +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'; + +import { getAlertHost } from '../common/alert-service'; + +import Timeline, { + TimelinePostInfoEx, + TimelineDeleteCallback, +} from './Timeline'; +import AppBar from '../common/AppBar'; +import TimelinePostEdit, { TimelinePostSendCallback } from './TimelinePostEdit'; +import { useEventEmiiter } from '../common'; +import { TimelineInfo } from '../data/timeline'; + +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; + posts?: TimelinePostInfoEx[] | 'forbid'; + 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 } = 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, props.posts]); + + 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 (props.posts != null) { + if (props.posts === 'forbid') { + timelineBody = ( +

{t('timeline.messageCantSee')}

+ ); + } else { + 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} +
+ + ); +} diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx new file mode 100644 index 00000000..88cc2226 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import { ExcludeKey } from '../utilities/type'; + +import TimelinePageTemplateUI, { + TimelinePageTemplateUIProps, +} from './TimelinePageTemplateUI'; +import TimelineInfoCard, { + OrdinaryTimelineManageItem, +} from './TimelineInfoCard'; + +export type TimelinePageUIProps = ExcludeKey< + TimelinePageTemplateUIProps, + 'CardComponent' +>; + +const TimelinePageUI: React.FC = (props) => { + return ; +}; + +export default TimelinePageUI; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx new file mode 100644 index 00000000..d4d626ae --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx @@ -0,0 +1,234 @@ +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 = (props) => { + const { onSelect } = props; + const { t } = useTranslation(); + + const [file, setFile] = React.useState(null); + const [fileUrl, setFileUrl] = React.useState(null); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + if (file != null) { + const url = URL.createObjectURL(file); + setFileUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + } + }, [file]); + + const onInputChange: React.ChangeEventHandler = 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 ( + <> + + {fileUrl && error == null && ( + + )} + {error != null &&
{t(error)}
} + + ); +}; + +export type TimelinePostSendCallback = ( + content: TimelineCreatePostRequest +) => Promise; + +export interface TimelinePostEditProps { + className?: string; + onPost: TimelinePostSendCallback; + onHeightChange?: (height: number) => void; + timelineUniqueId: string; +} + +const TimelinePostEdit: React.FC = (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(''); + const [imageBlob, setImageBlob] = React.useState(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(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 ( +
+ + + {kind === 'text' ? ( +