diff options
Diffstat (limited to 'Timeline/ClientApp/src/app/timeline')
13 files changed, 1623 insertions, 0 deletions
diff --git a/Timeline/ClientApp/src/app/timeline/Timeline.tsx b/Timeline/ClientApp/src/app/timeline/Timeline.tsx new file mode 100644 index 00000000..35d6490b --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/Timeline.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Container } from 'reactstrap'; + +import { TimelinePostInfo } from '../data/timeline'; +import { useUser } from '../data/user'; +import { useAvatarVersion } from '../user/api'; + +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; +} + +const Timeline: React.FC<TimelineProps> = (props) => { + const user = useUser(); + const avatarVersion = useAvatarVersion(); + + 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 ( + <Container + fluid + className={clsx('d-flex flex-column position-relative', props.className)} + > + <div className="timeline-enter-animation-mask" /> + {(() => { + const length = posts.length; + return posts.map((post, i) => { + const av: number | undefined = + user != null && user.username === post.author.username + ? avatarVersion + : undefined; + + 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} + avatarVersion={av} + onResize={onResize} + /> + ); + }); + })()} + </Container> + ); +}; + +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..5e7c6dd4 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import axios from 'axios'; +import { useHistory } from 'react-router'; +import { Trans } from 'react-i18next'; + +import { apiBaseUrl } from '../config'; +import { useUserLoggedIn } from '../data/user'; +import OperationDialog from '../common/OperationDialog'; + +interface TimelineDeleteDialog { + open: boolean; + name: string; + close: () => void; +} + +const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => { + const user = useUserLoggedIn(); + const history = useHistory(); + + const { name } = props; + + return ( + <OperationDialog + open={props.open} + close={props.close} + title="timeline.deleteDialog.title" + titleColor="danger" + inputPrompt={() => { + return ( + <Trans i18nKey="timeline.deleteDialog.inputPrompt"> + 0<code className="mx-2">{{ name }}</code>2 + </Trans> + ); + }} + inputScheme={[ + { + type: 'text', + validator: (value) => { + if (value !== name) { + return 'timeline.deleteDialog.notMatch'; + } else { + return null; + } + }, + }, + ]} + onProcess={() => { + return axios.delete( + `${apiBaseUrl}/timelines/${name}?token=${user.token}` + ); + }} + 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..e4bc07d1 --- /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 { + timelineVisibilityTooltipTranslationMap, + TimelineInfo, +} from '../data/timeline'; +import { TimelineCardComponentProps } from './TimelinePageTemplateUI'; + +export type OrdinaryTimelineManageItem = 'delete'; + +export type TimelineInfoCardProps = TimelineCardComponentProps< + TimelineInfo, + OrdinaryTimelineManageItem +>; + +const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => { + const { onHeight, onManage } = props; + + const { t } = useTranslation(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const containerRef = React.useRef<HTMLDivElement>(null!); + + const notifyHeight = React.useCallback((): void => { + if (onHeight) { + onHeight(containerRef.current.getBoundingClientRect().height); + } + }, [onHeight]); + + React.useEffect(() => { + const subscription = fromEvent(window, 'resize').subscribe(notifyHeight); + return () => subscription.unsubscribe(); + }); + + const [manageDropdownOpen, setManageDropdownOpen] = React.useState<boolean>( + false + ); + const toggleManageDropdown = React.useCallback( + (): void => setManageDropdownOpen((old) => !old), + [] + ); + + return ( + <div + ref={containerRef} + className={clsx('rounded border p-2 bg-light', props.className)} + onTransitionEnd={notifyHeight} + > + <h3 className="text-primary mx-3 d-inline-block align-middle"> + {props.timeline.name} + </h3> + <div className="d-inline-block align-middle"> + <img + src={props.timeline.owner._links.avatar} + onLoad={notifyHeight} + className="avatar small rounded-circle" + /> + {props.timeline.owner.nickname} + <small className="ml-3 text-secondary"> + @{props.timeline.owner.username} + </small> + </div> + <p className="mb-0">{props.timeline.description}</p> + <small className="mt-1 d-block"> + {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])} + </small> + <div className="text-right mt-2"> + {onManage != null ? ( + <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}> + <DropdownToggle outline color="primary"> + {t('timeline.manage')} + </DropdownToggle> + <DropdownMenu> + <DropdownItem onClick={() => onManage('property')}> + {t('timeline.manageItem.property')} + </DropdownItem> + <DropdownItem onClick={props.onMember}> + {t('timeline.manageItem.member')} + </DropdownItem> + <DropdownItem divider /> + <DropdownItem + className="text-danger" + onClick={() => onManage('delete')} + > + {t('timeline.manageItem.delete')} + </DropdownItem> + </DropdownMenu> + </Dropdown> + ) : ( + <Button color="primary" outline onClick={props.onMember}> + {t('timeline.memberButton')} + </Button> + )} + </div> + </div> + ); +}; + +export default TimelineInfoCard; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx new file mode 100644 index 00000000..4737fd7d --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx @@ -0,0 +1,175 @@ +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 { TimelinePostInfo } from '../data/timeline'; +import { useAvatarUrlWithGivenVersion } from '../user/api'; + +const TimelinePostDeleteConfirmDialog: React.FC<{ + toggle: () => void; + onConfirm: () => void; +}> = ({ toggle, onConfirm }) => { + const { t } = useTranslation(); + + return ( + <Modal toggle={toggle} isOpen centered> + <ModalHeader className="text-danger"> + {t('timeline.post.deleteDialog.title')} + </ModalHeader> + <ModalBody>{t('timeline.post.deleteDialog.prompt')}</ModalBody> + <ModalFooter> + <Button color="secondary" onClick={toggle}> + {t('operationDialog.cancel')} + </Button> + <Button + color="danger" + onClick={() => { + onConfirm(); + toggle(); + }} + > + {t('operationDialog.confirm')} + </Button> + </ModalFooter> + </Modal> + ); +}; + +export interface TimelineItemProps { + post: TimelinePostInfo; + current?: boolean; + more?: { + isOpen: boolean; + toggle: () => void; + onDelete: () => void; + }; + onClick?: () => void; + avatarVersion?: number; + 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 avatarUrl = useAvatarUrlWithGivenVersion( + props.avatarVersion, + props.post.author._links.avatar + ); + + const [deleteDialog, setDeleteDialog] = React.useState<boolean>(false); + const toggleDeleteDialog = React.useCallback( + () => setDeleteDialog((old) => !old), + [] + ); + + return ( + <Row + className={clsx( + 'position-relative flex-nowrap', + current && 'current', + props.className + )} + onClick={props.onClick} + style={props.style} + > + <Col className="timeline-line-area"> + <div className="timeline-line-segment start"></div> + <div className="timeline-line-node-container"> + <div className="timeline-line-node"></div> + </div> + <div className="timeline-line-segment end"></div> + {current && <div className="timeline-line-segment current-end" />} + </Col> + <Col className="timeline-pt-start"> + <Row className="flex-nowrap"> + <div className="col-auto flex-shrink-1 px-0"> + <Row className="ml-n3 mr-0 align-items-center"> + <span className="ml-3 text-primary white-space-no-wrap"> + {props.post.time.toLocaleString(i18n.languages)} + </span> + <small className="text-dark ml-3"> + {props.post.author.nickname} + </small> + </Row> + </div> + {more != null ? ( + <div className="col-auto px-2 d-flex justify-content-center align-items-center"> + <i + className="fas fa-chevron-circle-down text-info icon-button" + onClick={(e) => { + more.toggle(); + e.stopPropagation(); + }} + /> + </div> + ) : null} + </Row> + <p className="row d-block timeline-content"> + <Link + className="float-right float-sm-left mx-2" + to={'/users/' + props.post.author.username} + > + <img onLoad={onResize} src={avatarUrl} className="avatar rounded" /> + </Link> + {(() => { + const { content } = props.post; + if (content.type === 'text') { + return content.text; + } else { + return ( + <img + onLoad={onResize} + src={content.url} + className="timeline-content-image" + /> + ); + } + })()} + </p> + </Col> + {more != null && more.isOpen ? ( + <> + <div + className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center" + onClick={more.toggle} + > + <i + className="fas fa-trash text-danger large-icon" + onClick={(e) => { + toggleDeleteDialog(); + e.stopPropagation(); + }} + /> + </div> + {deleteDialog ? ( + <TimelinePostDeleteConfirmDialog + toggle={() => { + toggleDeleteDialog(); + more.toggle(); + }} + onConfirm={more.onDelete} + /> + ) : null} + </> + ) : null} + </Row> + ); +}; + +export default TimelineItem; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx new file mode 100644 index 00000000..f9747b4d --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx @@ -0,0 +1,196 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { User } from '../data/user'; + +import SearchInput from '../common/SearchInput'; +import { + Container, + ListGroup, + ListGroupItem, + Modal, + Row, + Col, + Button, +} from 'reactstrap'; + +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 members = props.members; + + return ( + <Container className="px-4"> + <ListGroup className="my-3"> + {members.map((member, index) => ( + <ListGroupItem key={member.username} className="container"> + <Row> + <Col className="col-auto"> + <img src={member._links.avatar} className="avatar small" /> + </Col> + <Col> + <Row>{member.nickname}</Row> + <Row> + <small>{'@' + member.username}</small> + </Row> + </Col> + {(() => { + if (index === 0) { + return null; + } + const onRemove = props.edit?.onRemoveUser; + if (onRemove == null) { + return null; + } + return ( + <Button + className="align-self-center" + color="danger" + onClick={() => { + onRemove(member.username); + }} + > + {t('timeline.member.remove')} + </Button> + ); + })()} + </Row> + </ListGroupItem> + ))} + </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"> + <img + src={u._links.avatar} + className="avatar small" + /> + </Col> + <Col> + <Row>{u.nickname}</Row> + <Row> + <small>{'@' + u.username}</small> + </Row> + </Col> + <Button + color="primary" + className="align-self-center" + disabled={!addable} + onClick={() => { + void edit.onAddUser(u).then((_) => { + setUserSearchText(''); + setUserSearchState({ type: 'init' }); + }); + }} + > + {t('timeline.member.add')} + </Button> + </Row> + </Container> + </> + ); + } else if (userSearchState.type === 'error') { + return ( + <p className="text-danger">{t(userSearchState.data)}</p> + ); + } + })()} + </> + ); + } else { + return null; + } + })()} + </Container> + ); +}; + +export default TimelineMember; + +export interface TimelineMemberDialogProps extends TimelineMemberProps { + open: boolean; + onClose: () => void; +} + +export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = ( + props +) => { + return ( + <Modal isOpen={props.open} toggle={props.onClose}> + <TimelineMember {...props} /> + </Modal> + ); +}; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx new file mode 100644 index 00000000..900d6e6a --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useParams } from 'react-router'; + +import { ordinaryTimelineService } from '../data/timeline'; + +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<OrdinaryTimelineManageItem | null>( + null + ); + + let dialogElement: React.ReactElement | undefined; + if (dialog === 'delete') { + dialogElement = ( + <TimelineDeleteDialog open close={() => setDialog(null)} name={name} /> + ); + } + + return ( + <> + <TimelinePageTemplate + name={name} + UiComponent={TimelinePageUI} + onManage={item => setDialog(item)} + service={ordinaryTimelineService} + 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..38ecd8f9 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx @@ -0,0 +1,275 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { AxiosError } from 'axios'; +import concat from 'lodash/concat'; +import without from 'lodash/without'; + +import { ExcludeKey } from '../type-utilities'; +import { useUser, fetchUser } from '../data/user'; +import { pushAlert } from '../common/alert-service'; +import { extractStatusCode, extractErrorCode } from '../data/common'; +import { + TimelineServiceTemplate, + TimelineInfo, + TimelineChangePropertyRequest, +} 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; + service: TimelineServiceTemplate<TTimeline, TimelineChangePropertyRequest>; + UiComponent: React.ComponentType< + ExcludeKey< + TimelinePageTemplateUIProps<TTimeline, TManageItem>, + 'CardComponent' + > + >; + dataVersion?: number; + notFoundI18nKey: string; +} + +export default function TimelinePageTemplate< + TManageItem, + TTimeline extends TimelineInfo +>( + props: TimelinePageTemplateProps<TManageItem, TTimeline> +): React.ReactElement | null { + const { t } = useTranslation(); + + const { name } = props; + + const user = useUser(); + + const [dialog, setDialog] = React.useState<null | 'property' | 'member'>( + null + ); + const [timeline, setTimeline] = React.useState<TTimeline | undefined>( + undefined + ); + const [posts, setPosts] = React.useState< + TimelinePostInfoEx[] | 'forbid' | undefined + >(undefined); + const [error, setError] = React.useState<string | undefined>(undefined); + + const service = props.service; + + React.useEffect(() => { + let subscribe = true; + service.fetch(name).then( + (ti) => { + if (subscribe) { + setTimeline(ti); + if (!service.hasReadPermission(user, ti)) { + setPosts('forbid'); + } else { + service.fetchPosts(name).then( + (data) => { + if (subscribe) { + setPosts( + data.map((post) => ({ + ...post, + deletable: service.hasModifyPostPermission( + user, + ti, + post + ), + })) + ); + } + }, + (error) => { + if (subscribe) { + setError(`${error as string}`); + } + } + ); + } + } + }, + (error: AxiosError) => { + if (subscribe) { + if ( + extractStatusCode(error) === 404 || + extractErrorCode(error) === 11020101 + ) { + setError(t(props.notFoundI18nKey)); + } else { + setError(error.toString()); + } + } + } + ); + return () => { + subscribe = false; + }; + }, [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 = ( + <TimelinePropertyChangeDialog + open + close={closeDialog} + oldInfo={{ + visibility: timeline.visibility, + description: timeline.description, + }} + onProcess={(req) => { + return service.changeProperty(name, req).then((newTimeline) => { + setTimeline(newTimeline); + }); + }} + /> + ); + } 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 fetchUser(u).catch((e) => { + if ( + extractStatusCode(e) === 404 || + extractErrorCode(e) === 11020101 + ) { + return Promise.resolve(null); + } else { + return Promise.reject(e); + } + }); + }, + onAddUser: (u) => { + return service.addMember(name, u.username).then((_) => { + setTimeline({ + ...timeline, + members: concat(timeline.members, u), + }); + }); + }, + onRemoveUser: (u) => { + void service.removeMember(name, u).then((_) => { + setTimeline({ + ...timeline, + members: without( + timeline.members, + timeline.members.find((m) => m.username === u) + ), + }); + }); + }, + } + : null + } + /> + ); + } + + const { UiComponent } = props; + + const onDelete: TimelineDeleteCallback = React.useCallback( + (index, id) => { + service.deletePost(name, id).then( + (_) => { + 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).then((newPost) => { + setPosts((oldPosts) => + concat(oldPosts as TimelinePostInfoEx[], { + ...newPost, + deletable: true, + }) + ); + }); + }, + [service, name] + ); + + const onManageProp = props.onManage; + + const onManage = React.useCallback( + (item: 'property' | TManageItem) => { + if (item === 'property') { + setDialog(item); + } else { + onManageProp(item); + } + }, + [onManageProp] + ); + + const onMember = React.useCallback(() => { + setDialog('member'); + }, []); + + return ( + <> + <UiComponent + error={error} + timeline={timeline} + posts={posts} + onDelete={onDelete} + onPost={ + timeline != null && service.hasPostPermission(user, timeline) + ? onPost + : undefined + } + onManage={ + timeline != null && service.hasManagePermission(user, timeline) + ? onManage + : undefined + } + onMember={onMember} + /> + {dialogElement} + </> + ); +} diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx new file mode 100644 index 00000000..d74fffc4 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx @@ -0,0 +1,212 @@ +import React from 'react'; +import { Spinner } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; +import { Subject, fromEvent } from 'rxjs'; + +import { getAlertHost } from '../common/alert-service'; + +import Timeline, { + TimelinePostInfoEx, + TimelineDeleteCallback, +} from './Timeline'; +import AppBar from '../common/AppBar'; +import TimelinePostEdit, { TimelinePostSendCallback } from './TimelinePostEdit'; +import CollapseButton from '../common/CollapseButton'; + +export interface TimelineCardComponentProps<TTimeline, TManageItems> { + timeline: TTimeline; + onManage?: (item: TManageItems | 'property') => void; + onMember: () => void; + className?: string; + onHeight?: (height: number) => void; +} + +export interface TimelinePageTemplateUIProps< + TTimeline extends { name: string }, + TManageItems +> { + avatarKey?: string | number; + timeline?: TTimeline; + posts?: TimelinePostInfoEx[] | 'forbid'; + CardComponent: React.ComponentType< + TimelineCardComponentProps<TTimeline, TManageItems> + >; + onMember: () => void; + onManage?: (item: TManageItems | 'property') => void; + onPost?: TimelinePostSendCallback; + onDelete: TimelineDeleteCallback; + error?: string; +} + +export default function TimelinePageTemplateUI< + TTimeline extends { name: string }, + TEditItems +>( + props: TimelinePageTemplateUIProps<TTimeline, TEditItems> +): React.ReactElement | null { + const { timeline } = 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 resizeSubject = React.useMemo(() => new Subject(), []); + const triggerResizeEvent = React.useCallback(() => { + resizeSubject.next(null); + }, [resizeSubject]); + + React.useEffect(() => { + let scrollToBottom = true; + const disableScrollToBottom = (): void => { + scrollToBottom = false; + }; + + const subscriptions = [ + fromEvent(window, 'wheel').subscribe(disableScrollToBottom), + fromEvent(window, 'pointerdown').subscribe(disableScrollToBottom), + fromEvent(window, 'keydown').subscribe(disableScrollToBottom), + resizeSubject.subscribe(() => { + if (scrollToBottom) { + window.scrollTo(0, document.body.scrollHeight); + } + }), + ]; + + return () => { + subscriptions.forEach((s) => s.unsubscribe()); + }; + }, [resizeSubject, timeline, props.posts]); + + const [cardHeight, setCardHeight] = React.useState<number>(0); + + const onCardHeightChange = React.useCallback((height: number) => { + setCardHeight(height); + }, []); + + const genCardCollapseLocalStorageKey = (timelineName: string): string => + `timeline.${timelineName}.cardCollapse`; + + const cardCollapseLocalStorageKey = + timeline != null ? genCardCollapseLocalStorageKey(timeline.name) : null; + + const [infoCardCollapse, setInfoCardCollapse] = React.useState<boolean>(true); + React.useEffect(() => { + if (cardCollapseLocalStorageKey != null) { + const savedCollapse = + window.localStorage.getItem(cardCollapseLocalStorageKey) === 'true'; + setInfoCardCollapse(savedCollapse); + } + }, [cardCollapseLocalStorageKey]); + + let body: React.ReactElement; + + if (props.error != null) { + body = <p className="text-danger">{t(props.error)}</p>; + } else { + if (timeline != null) { + let timelineBody: React.ReactElement; + if (props.posts != null) { + if (props.posts === 'forbid') { + timelineBody = ( + <p className="text-danger">{t('timeline.messageCantSee')}</p> + ); + } else { + timelineBody = ( + <Timeline + posts={props.posts} + onDelete={props.onDelete} + onResize={triggerResizeEvent} + /> + ); + if (props.onPost != null) { + timelineBody = ( + <> + {timelineBody} + <div ref={bottomSpaceRef} className="flex-fix-length" /> + <TimelinePostEdit + onPost={props.onPost} + onHeightChange={onPostEditHeightChange} + timelineName={timeline.name} + /> + </> + ); + } + } + } else { + timelineBody = ( + <div className="full-viewport-center-child"> + <Spinner color="primary" type="grow" /> + </div> + ); + } + const { CardComponent } = props; + + body = ( + <> + <div + className="fixed-top mt-appbar info-card-container" + data-collapse={infoCardCollapse ? 'true' : 'false'} + > + <CollapseButton + collapse={infoCardCollapse} + onClick={() => { + const newState = !infoCardCollapse; + setInfoCardCollapse(newState); + window.localStorage.setItem( + genCardCollapseLocalStorageKey(timeline.name), + newState.toString() + ); + }} + className="float-right m-1 info-card-collapse-button text-orange" + /> + <CardComponent + timeline={timeline} + onManage={props.onManage} + onMember={props.onMember} + onHeight={onCardHeightChange} + className="info-card-content" + /> + </div> + {timelineBody} + </> + ); + } else { + body = ( + <div className="full-viewport-center-child"> + <Spinner color="primary" type="grow" /> + </div> + ); + } + } + + return ( + <> + <AppBar /> + <div> + <div + style={{ height: 56 + cardHeight }} + className="timeline-page-top-space flex-fix-length" + /> + {body} + </div> + </> + ); +} diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx new file mode 100644 index 00000000..d7e9d81b --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { ExcludeKey } from '../type-utilities'; +import { TimelineInfo } from '../data/timeline'; + +import TimelinePageTemplateUI, { + TimelinePageTemplateUIProps +} from './TimelinePageTemplateUI'; +import TimelineInfoCard, { + OrdinaryTimelineManageItem +} from './TimelineInfoCard'; + +export type TimelinePageUIProps = ExcludeKey< + TimelinePageTemplateUIProps<TimelineInfo, OrdinaryTimelineManageItem>, + 'CardComponent' +>; + +const TimelinePageUI: React.FC<TimelinePageUIProps> = props => { + return <TimelinePageTemplateUI {...props} CardComponent={TimelineInfoCard} />; +}; + +export default TimelinePageUI; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx new file mode 100644 index 00000000..f12b6892 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx @@ -0,0 +1,224 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Button, Spinner, Row, Col } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; + +import { pushAlert } from '../common/alert-service'; +import { CreatePostRequest } from '../data/timeline'; + +import FileInput from '../common/FileInput'; +import { UiLogicError } from '../common'; + +interface TimelinePostEditImageProps { + onSelect: (blob: Blob | null) => void; +} + +const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => { + const { onSelect } = props; + const { t } = useTranslation(); + + const [file, setFile] = React.useState<File | null>(null); + const [fileUrl, setFileUrl] = React.useState<string | null>(null); + const [error, setError] = React.useState<string | null>(null); + + React.useEffect(() => { + if (file != null) { + const url = URL.createObjectURL(file); + setFileUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + } + }, [file]); + + const onInputChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback( + (e) => { + const files = e.target.files; + if (files == null || files.length === 0) { + setFile(null); + setFileUrl(null); + } else { + setFile(files[0]); + } + onSelect(null); + setError(null); + }, + [onSelect] + ); + + const onImgLoad = React.useCallback(() => { + onSelect(file); + }, [onSelect, file]); + + const onImgError = React.useCallback(() => { + setError('loadImageError'); + }, []); + + return ( + <> + <FileInput + labelText={t('chooseImage')} + onChange={onInputChange} + accept="image/*" + className="mx-3 my-1" + /> + {fileUrl && error == null && ( + <img + src={fileUrl} + className="timeline-post-edit-image" + onLoad={onImgLoad} + onError={onImgError} + /> + )} + {error != null && <div className="text-danger">{t(error)}</div>} + </> + ); +}; + +export type TimelinePostSendCallback = ( + content: CreatePostRequest +) => Promise<void>; + +export interface TimelinePostEditProps { + className?: string; + onPost: TimelinePostSendCallback; + onHeightChange?: (height: number) => void; + timelineName: 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.timelineName}.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!); + + 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: CreatePostRequest = (() => { + switch (kind) { + case 'text': + return { + content: { + type: 'text', + text: text, + }, + } as CreatePostRequest; + case 'image': + if (imageBlob == null) { + throw new UiLogicError( + 'Content type is image but image blob is null.' + ); + } + return { + content: { + type: 'image', + data: imageBlob, + }, + } as CreatePostRequest; + default: + throw new UiLogicError('Unknown content type.'); + } + })(); + + onPost(req).then( + (_) => { + if (kind === 'text') { + setText(''); + window.localStorage.removeItem(draftLocalStorageKey); + } + setState('input'); + setKind('text'); + }, + (_) => { + pushAlert({ + type: 'danger', + message: t('timeline.sendPostFailed'), + }); + setState('input'); + } + ); + }, [onPost, kind, text, imageBlob, t, draftLocalStorageKey]); + + const onImageSelect = React.useCallback((blob: Blob | null) => { + setImageBlob(blob); + }, []); + + return ( + <div ref={containerRef} className="container-fluid fixed-bottom bg-light"> + <Row> + <Col className="px-1 py-1"> + {kind === 'text' ? ( + <textarea + className="w-100 h-100 timeline-post-edit" + value={text} + disabled={state === 'process'} + onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => { + const value = event.currentTarget.value; + setText(value); + window.localStorage.setItem(draftLocalStorageKey, value); + }} + /> + ) : ( + <TimelinePostEditImage onSelect={onImageSelect} /> + )} + </Col> + <Col sm="col-auto align-self-end m-1"> + {(() => { + if (state === 'input') { + return ( + <> + <i + className={clsx( + 'fas d-block text-center large-icon mt-1 mb-2', + kind === 'text' ? 'fa-image' : 'fa-font' + )} + onClick={toggleKind} + /> + <Button color="primary" onClick={onSend} disabled={!canSend}> + {t('timeline.send')} + </Button> + </> + ); + } else { + return <Spinner />; + } + })()} + </Col> + </Row> + </div> + ); +}; + +export default TimelinePostEdit; diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx new file mode 100644 index 00000000..ca1be31c --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx @@ -0,0 +1,70 @@ +import React from 'react'; + +import { + TimelineVisibility, + kTimelineVisibilities, + PersonalTimelineChangePropertyRequest +} from '../data/timeline'; + +import OperationDialog, { + OperationSelectInputInfoOption +} from '../common/OperationDialog'; + +export interface TimelinePropertyInfo { + visibility: TimelineVisibility; + description: string; +} + +export interface TimelinePropertyChangeDialogProps { + open: boolean; + close: () => void; + oldInfo: TimelinePropertyInfo; + onProcess: (request: PersonalTimelineChangePropertyRequest) => 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: PersonalTimelineChangePropertyRequest = {}; + if (newVisibility !== props.oldInfo.visibility) { + req.visibility = newVisibility as TimelineVisibility; + } + if (newDescription !== props.oldInfo.description) { + req.description = newDescription as string; + } + return props.onProcess(req); + }} + /> + ); +}; + +export default TimelinePropertyChangeDialog; diff --git a/Timeline/ClientApp/src/app/timeline/timeline-ui.sass b/Timeline/ClientApp/src/app/timeline/timeline-ui.sass new file mode 100644 index 00000000..c3616caf --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/timeline-ui.sass @@ -0,0 +1,18 @@ +.info-card-container + .info-card-collapse-button + z-index: 1 + position: relative + + .info-card-content + width: 100% + position: absolute + transform-origin: right top + transition: transform 0.5s + + &[data-collapse='true'] + .info-card-content + transform: scale(0) + +.timeline-page-top-space + transition: height 0.5s + diff --git a/Timeline/ClientApp/src/app/timeline/timeline.sass b/Timeline/ClientApp/src/app/timeline/timeline.sass new file mode 100644 index 00000000..0b0e73b5 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/timeline.sass @@ -0,0 +1,125 @@ +@use 'sass:color' + +@keyframes timeline-enter-animation-mask-animation + to + height: 0 + +.timeline-enter-animation-mask + position: absolute + left: 0 + top: 0 + height: calc(100% + 300px) + width: 100% + background: linear-gradient(to top, #ffffff00 0, 200px, white 300px, white) + z-index: 100 + animation: timeline-enter-animation-mask-animation 5s 0.3s forwards // Give it 0.3s to load, which I think is reasonable + +$timeline-line-width: 7px +$timeline-line-node-radius: 18px +$timeline-line-color: $primary +$timeline-line-color-current: #36c2e6 + +@keyframes timeline-line-node-noncurrent + from + background: $timeline-line-color + + to + background: color.adjust($timeline-line-color, $lightness: +10%) + box-shadow: 0 0 20px 3px color.adjust($timeline-line-color, $lightness: +10%, $alpha: -0.1) + + +@keyframes timeline-line-node-current + from + background: $timeline-line-color-current + + to + background: color.adjust($timeline-line-color-current, $lightness: +10%) + box-shadow: 0 0 20px 3px color.adjust($timeline-line-color-current, $lightness: +10%, $alpha: -0.1) + +.timeline-line + &-area + display: flex + flex-direction: column + align-items: center + flex: 0 0 auto + width: 60px + + &-segment + width: $timeline-line-width + background: $timeline-line-color + + &.start + height: 20px + flex: 0 0 auto + + &.end + flex: 1 1 auto + + &.current-end + height: 20px + flex: 0 0 auto + background: linear-gradient($timeline-line-color-current, transparent) + + &-node-container + flex: 0 0 auto + position: relative + width: $timeline-line-node-radius + height: $timeline-line-node-radius + + &-node + width: $timeline-line-node-radius + 2 + height: $timeline-line-node-radius + 2 + position: absolute + left: -1px + top: -1px + border-radius: 50% + box-sizing: border-box + z-index: 1 + animation: 1s infinite alternate + animation-name: timeline-line-node-noncurrent + + +.current + .timeline-line + &-segment + + &.start + background: linear-gradient($timeline-line-color, $timeline-line-color-current) + + &.end + background: $timeline-line-color-current + + &-node + animation-name: timeline-line-node-current + +.timeline-pt-start + padding-top: 18px + +.timeline-item-delete-button + position: absolute + right: 0 + bottom: 0 + +.timeline-content + white-space: pre-line + +.timeline-content-image + max-width: 60% + max-height: 200px + + +.timeline-post-edit-image + max-width: 100px + max-height: 100px + +.mask + background: change-color($color: white, $alpha: 0.8) + z-index: 100 + +textarea.timeline-post-edit + @extend .border-primary + @extend .rounded + + &:focus + outline: none + box-shadow: 0 0 5px 0 $primary |