diff options
Diffstat (limited to 'Timeline/ClientApp/src/app/timeline')
13 files changed, 0 insertions, 1673 deletions
diff --git a/Timeline/ClientApp/src/app/timeline/Timeline.tsx b/Timeline/ClientApp/src/app/timeline/Timeline.tsx deleted file mode 100644 index 0a68c5db..00000000 --- a/Timeline/ClientApp/src/app/timeline/Timeline.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React from 'react'; -import clsx from 'clsx'; - -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; - containerRef?: React.Ref<HTMLDivElement>; -} - -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 ( - <div - ref={props.containerRef} - className={clsx( - 'container-fluid 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} - /> - ); - }); - })()} - </div> - ); -}; - -export default Timeline; diff --git a/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx deleted file mode 100644 index 5e7c6dd4..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index e4bc07d1..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx +++ /dev/null @@ -1,109 +0,0 @@ -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 deleted file mode 100644 index 43e206f1..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx +++ /dev/null @@ -1,181 +0,0 @@ -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 { 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"> - <Svg - src={chevronDownIcon} - className="text-info icon-button" - onClick={(e: Event) => { - more.toggle(); - e.stopPropagation(); - }} - /> - </div> - ) : null} - </Row> - <div 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" - /> - ); - } - })()} - </div> - </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} - > - <Svg - src={trashIcon} - className="text-danger large-icon-button" - onClick={(e: Event) => { - 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 deleted file mode 100644 index f9747b4d..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx +++ /dev/null @@ -1,196 +0,0 @@ -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 deleted file mode 100644 index 900d6e6a..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 38ecd8f9..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx +++ /dev/null @@ -1,275 +0,0 @@ -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 deleted file mode 100644 index 924e7883..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx +++ /dev/null @@ -1,243 +0,0 @@ -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'; - -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 { uniqueId: 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 { uniqueId: 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 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, props.posts]); - - const [cardHeight, setCardHeight] = React.useState<number>(0); - - const genCardCollapseLocalStorageKey = (uniqueId: string): string => - `timeline.${uniqueId}.cardCollapse`; - - const cardCollapseLocalStorageKey = - timeline != null ? genCardCollapseLocalStorageKey(timeline.uniqueId) : 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 - containerRef={timelineRef} - 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} - timelineUniqueId={timeline.uniqueId} - /> - </> - ); - } - } - } 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'} - > - <Svg - src={ - infoCardCollapse - ? arrowsAngleExpandIcon - : arrowsAngleContractIcon - } - onClick={() => { - 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" - /> - <CardComponent - timeline={timeline} - onManage={props.onManage} - onMember={props.onMember} - onHeight={setCardHeight} - 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 deleted file mode 100644 index d7e9d81b..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 894d6ad4..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx +++ /dev/null @@ -1,234 +0,0 @@ -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 { 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; - 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: 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 ( - <> - <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 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 deleted file mode 100644 index ca1be31c..00000000 --- a/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index c3616caf..00000000 --- a/Timeline/ClientApp/src/app/timeline/timeline-ui.sass +++ /dev/null @@ -1,18 +0,0 @@ -.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 deleted file mode 100644 index 0b0e73b5..00000000 --- a/Timeline/ClientApp/src/app/timeline/timeline.sass +++ /dev/null @@ -1,125 +0,0 @@ -@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 |