diff options
Diffstat (limited to 'Timeline/ClientApp/src/app/timeline')
13 files changed, 1685 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..849933cf --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/Timeline.tsx @@ -0,0 +1,91 @@ +import React from 'react';
+import clsx from 'clsx';
+
+import { TimelinePostInfo } from '../data/timeline';
+
+import TimelineItem from './TimelineItem';
+
+export interface TimelinePostInfoEx extends TimelinePostInfo {
+ deletable: boolean;
+}
+
+export type TimelineDeleteCallback = (index: number, id: number) => void;
+
+export interface TimelineProps {
+ className?: string;
+ posts: TimelinePostInfoEx[];
+ onDelete: TimelineDeleteCallback;
+ onResize?: () => void;
+ containerRef?: React.Ref<HTMLDivElement>;
+}
+
+const Timeline: React.FC<TimelineProps> = (props) => {
+ const { posts, onDelete, onResize } = props;
+
+ const [indexShowDeleteButton, setIndexShowDeleteButton] = React.useState<
+ number
+ >(-1);
+
+ const onItemClick = React.useCallback(() => {
+ setIndexShowDeleteButton(-1);
+ }, []);
+
+ const onToggleDelete = React.useMemo(() => {
+ return posts.map((post, i) => {
+ return post.deletable
+ ? () => {
+ setIndexShowDeleteButton((oldIndexShowDeleteButton) => {
+ return oldIndexShowDeleteButton !== i ? i : -1;
+ });
+ }
+ : undefined;
+ });
+ }, [posts]);
+
+ const onItemDelete = React.useMemo(() => {
+ return posts.map((post, i) => {
+ return () => {
+ onDelete(i, post.id);
+ };
+ });
+ }, [posts, onDelete]);
+
+ return (
+ <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 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}
+ onResize={onResize}
+ />
+ );
+ });
+ })()}
+ </div>
+ );
+};
+
+export default Timeline;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx new file mode 100644 index 00000000..7bcea6c5 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx @@ -0,0 +1,54 @@ +import React from 'react';
+import { useHistory } from 'react-router';
+import { Trans } from 'react-i18next';
+
+import OperationDialog from '../common/OperationDialog';
+import { timelineService } from '../data/timeline';
+
+interface TimelineDeleteDialog {
+ open: boolean;
+ name: string;
+ close: () => void;
+}
+
+const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
+ 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 timelineService.deleteTimeline(name).toPromise();
+ }}
+ onSuccessAndClose={() => {
+ history.replace('/');
+ }}
+ />
+ );
+};
+
+export default TimelineDeleteDialog;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx new file mode 100644 index 00000000..c25b2376 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx @@ -0,0 +1,109 @@ +import React from 'react';
+import clsx from 'clsx';
+import {
+ Dropdown,
+ DropdownToggle,
+ DropdownMenu,
+ DropdownItem,
+ Button,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import { fromEvent } from 'rxjs';
+
+import { useAvatarUrl } from '../data/user';
+import { timelineVisibilityTooltipTranslationMap } from '../data/timeline';
+
+import { TimelineCardComponentProps } from './TimelinePageTemplateUI';
+
+export type OrdinaryTimelineManageItem = 'delete';
+
+export type TimelineInfoCardProps = TimelineCardComponentProps<
+ OrdinaryTimelineManageItem
+>;
+
+const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
+ const { onHeight, onManage } = props;
+
+ const { t } = useTranslation();
+
+ const avatarUrl = useAvatarUrl(props.timeline.owner.username);
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const containerRef = React.useRef<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={avatarUrl}
+ 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..11ac9f08 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx @@ -0,0 +1,183 @@ +import React from 'react';
+import clsx from 'clsx';
+import {
+ Row,
+ Col,
+ Modal,
+ ModalHeader,
+ ModalBody,
+ ModalFooter,
+ Button,
+} from 'reactstrap';
+import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import Svg from 'react-inlinesvg';
+
+import chevronDownIcon from 'bootstrap-icons/icons/chevron-down.svg';
+import trashIcon from 'bootstrap-icons/icons/trash.svg';
+
+import { useAvatarUrl } from '../data/user';
+import { TimelinePostInfo, usePostDataUrl } from '../data/timeline';
+
+const TimelinePostDeleteConfirmDialog: React.FC<{
+ toggle: () => void;
+ onConfirm: () => void;
+}> = ({ toggle, onConfirm }) => {
+ const { t } = useTranslation();
+
+ return (
+ <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;
+ 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 = useAvatarUrl(props.post.author.username);
+
+ const dataUrl = usePostDataUrl(
+ props.post.content.type === 'image',
+ props.post.timelineName,
+ props.post.id
+ );
+
+ 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={dataUrl}
+ 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 new file mode 100644 index 00000000..8c637f46 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx @@ -0,0 +1,218 @@ +import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ Container,
+ ListGroup,
+ ListGroupItem,
+ Modal,
+ Row,
+ Col,
+ Button,
+} from 'reactstrap';
+
+import { User, useAvatarUrl } from '../data/user';
+
+import SearchInput from '../common/SearchInput';
+
+const TimelineMemberItem: React.FC<{
+ user: User;
+ owner: boolean;
+ onRemove?: (username: string) => void;
+}> = ({ user, owner, onRemove }) => {
+ const { t } = useTranslation();
+
+ const avatarUrl = useAvatarUrl(user.username);
+
+ return (
+ <ListGroupItem className="container">
+ <Row>
+ <Col className="col-auto">
+ <img src={avatarUrl} className="avatar small" />
+ </Col>
+ <Col>
+ <Row>{user.nickname}</Row>
+ <Row>
+ <small>{'@' + user.username}</small>
+ </Row>
+ </Col>
+ {(() => {
+ if (owner) {
+ return null;
+ }
+ if (onRemove == null) {
+ return null;
+ }
+ return (
+ <Button
+ className="align-self-center"
+ color="danger"
+ onClick={() => {
+ onRemove(user.username);
+ }}
+ >
+ {t('timeline.member.remove')}
+ </Button>
+ );
+ })()}
+ </Row>
+ </ListGroupItem>
+ );
+};
+
+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 userSearchAvatarUrl = useAvatarUrl(
+ userSearchState.type === 'user' ? userSearchState.data.username : undefined
+ );
+
+ const members = props.members;
+
+ return (
+ <Container className="px-4">
+ <ListGroup className="my-3">
+ {members.map((member, index) => (
+ <TimelineMemberItem
+ key={member.username}
+ user={member}
+ owner={index === 0}
+ onRemove={props.edit?.onRemoveUser}
+ />
+ ))}
+ </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={userSearchAvatarUrl}
+ 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..7d0a8807 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx @@ -0,0 +1,36 @@ +import React from 'react';
+import { useParams } from 'react-router';
+
+import TimelinePageUI from './TimelinePageUI';
+import TimelinePageTemplate from '../timeline/TimelinePageTemplate';
+import { OrdinaryTimelineManageItem } from './TimelineInfoCard';
+import TimelineDeleteDialog from './TimelineDeleteDialog';
+
+const TimelinePage: React.FC = (_) => {
+ const { name } = useParams<{ name: string }>();
+
+ const [dialog, setDialog] = React.useState<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)}
+ notFoundI18nKey="timeline.timelineNotExist"
+ />
+ {dialogElement}
+ </>
+ );
+};
+
+export default TimelinePage;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx new file mode 100644 index 00000000..9be7f305 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx @@ -0,0 +1,288 @@ +import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { concat, without } from 'lodash';
+import { of } from 'rxjs';
+import { catchError, switchMap, map } from 'rxjs/operators';
+
+import { ExcludeKey } from '../utilities/type';
+import { pushAlert } from '../common/alert-service';
+import { useUser, userInfoService, UserNotExistError } from '../data/user';
+import {
+ timelineService,
+ TimelineInfo,
+ TimelineNotExistError,
+} from '../data/timeline';
+
+import { TimelinePostInfoEx, TimelineDeleteCallback } from './Timeline';
+import { TimelineMemberDialog } from './TimelineMember';
+import TimelinePropertyChangeDialog from './TimelinePropertyChangeDialog';
+import { TimelinePageTemplateUIProps } from './TimelinePageTemplateUI';
+import { TimelinePostSendCallback } from './TimelinePostEdit';
+import { UiLogicError } from '../common';
+
+export interface TimelinePageTemplateProps<
+ TManageItem,
+ TTimeline extends TimelineInfo
+> {
+ name: string;
+ onManage: (item: TManageItem) => void;
+ UiComponent: React.ComponentType<
+ ExcludeKey<TimelinePageTemplateUIProps<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 service = timelineService;
+
+ const user = useUser();
+
+ const [dialog, setDialog] = React.useState<null | 'property' | 'member'>(
+ null
+ );
+ const [timeline, setTimeline] = React.useState<TimelineInfo | undefined>(
+ undefined
+ );
+ const [posts, setPosts] = React.useState<
+ TimelinePostInfoEx[] | 'forbid' | undefined
+ >(undefined);
+ const [error, setError] = React.useState<string | undefined>(undefined);
+
+ React.useEffect(() => {
+ const subscription = service
+ .getTimeline(name)
+ .pipe(
+ switchMap((ti) => {
+ setTimeline(ti);
+ if (!service.hasReadPermission(user, ti)) {
+ setPosts('forbid');
+ return of(null);
+ } else {
+ return service
+ .getPosts(name)
+ .pipe(map((ps) => ({ timeline: ti, posts: ps })));
+ }
+ })
+ )
+ .subscribe(
+ (data) => {
+ if (data != null) {
+ setPosts(
+ data.posts.map((post) => ({
+ ...post,
+ deletable: service.hasModifyPostPermission(
+ user,
+ data.timeline,
+ post
+ ),
+ }))
+ );
+ }
+ },
+ (error) => {
+ if (error instanceof TimelineNotExistError) {
+ setError(t(props.notFoundI18nKey));
+ } else {
+ setError(
+ // TODO: Convert this to a function.
+ (error as { message?: string })?.message ?? 'Unknown error'
+ );
+ }
+ }
+ );
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [name, service, user, t, props.dataVersion, props.notFoundI18nKey]);
+
+ const closeDialog = React.useCallback((): void => {
+ setDialog(null);
+ }, []);
+
+ let dialogElement: React.ReactElement | undefined;
+
+ if (dialog === 'property') {
+ if (timeline == null) {
+ throw new UiLogicError(
+ 'Timeline is null but attempt to open change property dialog.'
+ );
+ }
+
+ dialogElement = (
+ <TimelinePropertyChangeDialog
+ open
+ close={closeDialog}
+ oldInfo={{
+ visibility: timeline.visibility,
+ description: timeline.description,
+ }}
+ onProcess={(req) => {
+ return service
+ .changeTimelineProperty(name, req)
+ .pipe(
+ map((newTimeline) => {
+ setTimeline(newTimeline);
+ })
+ )
+ .toPromise();
+ }}
+ />
+ );
+ } else if (dialog === 'member') {
+ if (timeline == null) {
+ throw new UiLogicError(
+ 'Timeline is null but attempt to open change property dialog.'
+ );
+ }
+
+ dialogElement = (
+ <TimelineMemberDialog
+ open
+ onClose={closeDialog}
+ members={[timeline.owner, ...timeline.members]}
+ edit={
+ service.hasManagePermission(user, timeline)
+ ? {
+ onCheckUser: (u) => {
+ return userInfoService
+ .getUserInfo(u)
+ .pipe(
+ catchError((e) => {
+ if (e instanceof UserNotExistError) {
+ return of(null);
+ } else {
+ throw e;
+ }
+ })
+ )
+ .toPromise();
+ },
+ onAddUser: (u) => {
+ return service
+ .addMember(name, u.username)
+ .pipe(
+ map(() => {
+ setTimeline({
+ ...timeline,
+ members: concat(timeline.members, u),
+ });
+ })
+ )
+ .toPromise();
+ },
+ onRemoveUser: (u) => {
+ service.removeMember(name, u).subscribe(() => {
+ const toDelete = timeline.members.find(
+ (m) => m.username === u
+ );
+ if (toDelete == null) {
+ throw new UiLogicError(
+ 'The member to delete is not in list.'
+ );
+ }
+ setTimeline({
+ ...timeline,
+ members: without(timeline.members, toDelete),
+ });
+ });
+ },
+ }
+ : null
+ }
+ />
+ );
+ }
+
+ const { UiComponent } = props;
+
+ const onDelete: TimelineDeleteCallback = React.useCallback(
+ (index, id) => {
+ service.deletePost(name, id).subscribe(
+ () => {
+ setPosts((oldPosts) =>
+ without(
+ oldPosts as TimelinePostInfoEx[],
+ (oldPosts as TimelinePostInfoEx[])[index]
+ )
+ );
+ },
+ () => {
+ pushAlert({
+ type: 'danger',
+ message: t('timeline.deletePostFailed'),
+ });
+ }
+ );
+ },
+ [service, name, t]
+ );
+
+ const onPost: TimelinePostSendCallback = React.useCallback(
+ (req) => {
+ return service
+ .createPost(name, req)
+ .pipe(
+ map((newPost) => {
+ setPosts((oldPosts) =>
+ concat(oldPosts as TimelinePostInfoEx[], {
+ ...newPost,
+ deletable: true,
+ })
+ );
+ })
+ )
+ .toPromise();
+ },
+ [service, name]
+ );
+
+ const onManageProp = props.onManage;
+
+ const onManage = React.useCallback(
+ (item: 'property' | TManageItem) => {
+ if (item === 'property') {
+ setDialog(item);
+ } else {
+ onManageProp(item);
+ }
+ },
+ [onManageProp]
+ );
+
+ const onMember = React.useCallback(() => {
+ setDialog('member');
+ }, []);
+
+ return (
+ <>
+ <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..4b3b3096 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx @@ -0,0 +1,236 @@ +import React from 'react';
+import { Spinner } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import { fromEvent } from 'rxjs';
+import Svg from 'react-inlinesvg';
+
+import arrowsAngleContractIcon from 'bootstrap-icons/icons/arrows-angle-contract.svg';
+import arrowsAngleExpandIcon from 'bootstrap-icons/icons/arrows-angle-expand.svg';
+
+import { getAlertHost } from '../common/alert-service';
+
+import Timeline, {
+ TimelinePostInfoEx,
+ TimelineDeleteCallback,
+} from './Timeline';
+import AppBar from '../common/AppBar';
+import TimelinePostEdit, { TimelinePostSendCallback } from './TimelinePostEdit';
+import { useEventEmiiter } from '../common';
+import { TimelineInfo } from '../data/timeline';
+
+export interface TimelineCardComponentProps<TManageItems> {
+ timeline: TimelineInfo;
+ onManage?: (item: TManageItems | 'property') => void;
+ onMember: () => void;
+ className?: string;
+ onHeight?: (height: number) => void;
+}
+
+export interface TimelinePageTemplateUIProps<TManageItems> {
+ avatarKey?: string | number;
+ timeline?: TimelineInfo;
+ posts?: TimelinePostInfoEx[] | 'forbid';
+ CardComponent: React.ComponentType<TimelineCardComponentProps<TManageItems>>;
+ onMember: () => void;
+ onManage?: (item: TManageItems | 'property') => void;
+ onPost?: TimelinePostSendCallback;
+ onDelete: TimelineDeleteCallback;
+ error?: string;
+}
+
+export default function TimelinePageTemplateUI<TManageItems>(
+ props: TimelinePageTemplateUIProps<TManageItems>
+): 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 new file mode 100644 index 00000000..88cc2226 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx @@ -0,0 +1,21 @@ +import React from 'react';
+
+import { ExcludeKey } from '../utilities/type';
+
+import TimelinePageTemplateUI, {
+ TimelinePageTemplateUIProps,
+} from './TimelinePageTemplateUI';
+import TimelineInfoCard, {
+ OrdinaryTimelineManageItem,
+} from './TimelineInfoCard';
+
+export type TimelinePageUIProps = ExcludeKey<
+ TimelinePageTemplateUIProps<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..d4d626ae --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx @@ -0,0 +1,234 @@ +import React from 'react';
+import { Button, Spinner, Row, Col } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import Svg from 'react-inlinesvg';
+
+import textIcon from 'bootstrap-icons/icons/card-text.svg';
+import imageIcon from 'bootstrap-icons/icons/image.svg';
+
+import { pushAlert } from '../common/alert-service';
+import { TimelineCreatePostRequest } from '../data/timeline';
+
+import FileInput from '../common/FileInput';
+import { UiLogicError } from '../common';
+
+interface TimelinePostEditImageProps {
+ onSelect: (blob: Blob | null) => void;
+}
+
+const TimelinePostEditImage: React.FC<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: TimelineCreatePostRequest
+) => 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: TimelineCreatePostRequest = (() => {
+ switch (kind) {
+ case 'text':
+ return {
+ content: {
+ type: 'text',
+ text: text,
+ },
+ } as TimelineCreatePostRequest;
+ case 'image':
+ if (imageBlob == null) {
+ throw new UiLogicError(
+ 'Content type is image but image blob is null.'
+ );
+ }
+ return {
+ content: {
+ type: 'image',
+ data: imageBlob,
+ },
+ } as TimelineCreatePostRequest;
+ default:
+ throw new UiLogicError('Unknown content type.');
+ }
+ })();
+
+ onPost(req).then(
+ (_) => {
+ if (kind === 'text') {
+ setText('');
+ window.localStorage.removeItem(draftLocalStorageKey);
+ }
+ setState('input');
+ setKind('text');
+ },
+ (_) => {
+ pushAlert({
+ type: 'danger',
+ message: t('timeline.sendPostFailed'),
+ });
+ setState('input');
+ }
+ );
+ }, [onPost, kind, text, imageBlob, t, draftLocalStorageKey]);
+
+ const onImageSelect = React.useCallback((blob: Blob | null) => {
+ setImageBlob(blob);
+ }, []);
+
+ return (
+ <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 new file mode 100644 index 00000000..93989884 --- /dev/null +++ b/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx @@ -0,0 +1,72 @@ +import React from 'react';
+
+import {
+ TimelineVisibility,
+ kTimelineVisibilities,
+ TimelineChangePropertyRequest,
+} 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: TimelineChangePropertyRequest) => 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: TimelineChangePropertyRequest = {};
+ 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..b92327bd --- /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..4f69295b --- /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
|