From 6f0b2b85df74199575eabee3abfd7734bd36df76 Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 13 Jul 2020 20:59:52 +0800 Subject: Move front end to a submodule. --- Timeline/ClientApp/src/app/timeline/Timeline.tsx | 102 -------- .../src/app/timeline/TimelineDeleteDialog.tsx | 59 ----- .../src/app/timeline/TimelineInfoCard.tsx | 109 -------- .../ClientApp/src/app/timeline/TimelineItem.tsx | 181 -------------- .../ClientApp/src/app/timeline/TimelineMember.tsx | 196 --------------- .../ClientApp/src/app/timeline/TimelinePage.tsx | 39 --- .../src/app/timeline/TimelinePageTemplate.tsx | 275 --------------------- .../src/app/timeline/TimelinePageTemplateUI.tsx | 243 ------------------ .../ClientApp/src/app/timeline/TimelinePageUI.tsx | 22 -- .../src/app/timeline/TimelinePostEdit.tsx | 234 ------------------ .../app/timeline/TimelinePropertyChangeDialog.tsx | 70 ------ .../ClientApp/src/app/timeline/timeline-ui.sass | 18 -- Timeline/ClientApp/src/app/timeline/timeline.sass | 125 ---------- 13 files changed, 1673 deletions(-) delete mode 100644 Timeline/ClientApp/src/app/timeline/Timeline.tsx delete mode 100644 Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx delete mode 100644 Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx delete mode 100644 Timeline/ClientApp/src/app/timeline/TimelineItem.tsx delete mode 100644 Timeline/ClientApp/src/app/timeline/TimelineMember.tsx delete mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePage.tsx delete mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx delete mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx delete mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx delete mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx delete mode 100644 Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx delete mode 100644 Timeline/ClientApp/src/app/timeline/timeline-ui.sass delete 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 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; -} - -const Timeline: React.FC = (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 ( -
-
- {(() => { - 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 ( - - ); - }); - })()} -
- ); -}; - -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 = (props) => { - const user = useUserLoggedIn(); - 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 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 = (props) => { - const { onHeight, onManage } = props; - - const { t } = useTranslation(); - - // 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 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 ( - - - {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; - avatarVersion?: number; - 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 = useAvatarUrlWithGivenVersion( - props.avatarVersion, - props.post.author._links.avatar - ); - - 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 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; - 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 members = props.members; - - return ( - - - {members.map((member, index) => ( - - - - - - - {member.nickname} - - {'@' + member.username} - - - {(() => { - if (index === 0) { - return null; - } - const onRemove = props.edit?.onRemoveUser; - if (onRemove == null) { - return null; - } - return ( - - ); - })()} - - - ))} - - {(() => { - 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 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( - null - ); - - let dialogElement: React.ReactElement | undefined; - if (dialog === 'delete') { - dialogElement = ( - setDialog(null)} name={name} /> - ); - } - - return ( - <> - 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; - UiComponent: React.ComponentType< - ExcludeKey< - TimelinePageTemplateUIProps, - '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 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); - - 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 = ( - { - 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 = ( - { - 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 ( - <> - - {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 { - 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 - >; - onMember: () => void; - onManage?: (item: TManageItems | 'property') => void; - onPost?: TimelinePostSendCallback; - onDelete: TimelineDeleteCallback; - error?: string; -} - -export default function TimelinePageTemplateUI< - TTimeline extends { uniqueId: string }, - TEditItems ->( - 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 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, - '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 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 = (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: CreatePostRequest -) => 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: 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 ( -
- - - {kind === 'text' ? ( -