aboutsummaryrefslogtreecommitdiff
path: root/Timeline/ClientApp/src/app/timeline
diff options
context:
space:
mode:
Diffstat (limited to 'Timeline/ClientApp/src/app/timeline')
-rw-r--r--Timeline/ClientApp/src/app/timeline/Timeline.tsx99
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx59
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx109
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineItem.tsx175
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineMember.tsx196
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePage.tsx39
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx275
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx212
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx22
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx224
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx70
-rw-r--r--Timeline/ClientApp/src/app/timeline/timeline-ui.sass18
-rw-r--r--Timeline/ClientApp/src/app/timeline/timeline.sass125
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