diff options
Diffstat (limited to 'Timeline/ClientApp/src/timeline')
-rw-r--r-- | Timeline/ClientApp/src/timeline/Timeline.tsx | 87 | ||||
-rw-r--r-- | Timeline/ClientApp/src/timeline/TimelineDeleteDialog.tsx | 59 | ||||
-rw-r--r-- | Timeline/ClientApp/src/timeline/TimelineInfoCard.tsx | 112 | ||||
-rw-r--r-- | Timeline/ClientApp/src/timeline/TimelineItem.tsx | 113 | ||||
-rw-r--r-- | Timeline/ClientApp/src/timeline/TimelineMember.tsx | 194 | ||||
-rw-r--r-- | Timeline/ClientApp/src/timeline/TimelinePage.tsx | 39 | ||||
-rw-r--r-- | Timeline/ClientApp/src/timeline/TimelinePageTemplate.tsx | 271 | ||||
-rw-r--r-- | Timeline/ClientApp/src/timeline/TimelinePageTemplateUI.tsx | 147 | ||||
-rw-r--r-- | Timeline/ClientApp/src/timeline/TimelinePageUI.tsx | 22 | ||||
-rw-r--r-- | Timeline/ClientApp/src/timeline/TimelinePostEdit.tsx | 205 | ||||
-rw-r--r-- | Timeline/ClientApp/src/timeline/TimelinePropertyChangeDialog.tsx | 70 | ||||
-rw-r--r-- | Timeline/ClientApp/src/timeline/timeline-ui.scss | 27 | ||||
-rw-r--r-- | Timeline/ClientApp/src/timeline/timeline.scss | 119 |
13 files changed, 1465 insertions, 0 deletions
diff --git a/Timeline/ClientApp/src/timeline/Timeline.tsx b/Timeline/ClientApp/src/timeline/Timeline.tsx new file mode 100644 index 00000000..defca4c3 --- /dev/null +++ b/Timeline/ClientApp/src/timeline/Timeline.tsx @@ -0,0 +1,87 @@ +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;
+}
+
+const Timeline: React.FC<TimelineProps> = (props) => {
+ const user = useUser();
+ const avatarVersion = useAvatarVersion();
+
+ const { posts, onDelete } = 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 className={clsx('pr-2', props.className)}>
+ <Container fluid className="d-flex flex-column">
+ {(() => {
+ const length = posts.length;
+ return posts.map((post, i) => {
+ const av: number | undefined =
+ user != null && user.username === post.author.username
+ ? avatarVersion
+ : undefined;
+
+ return (
+ <TimelineItem
+ post={post}
+ key={post.id}
+ current={length - 1 === i}
+ showDeleteButton={indexShowDeleteButton === i}
+ toggleMore={onToggleDelete[i]}
+ onDelete={onItemDelete[i]}
+ onClick={onItemClick}
+ avatarVersion={av}
+ />
+ );
+ });
+ })()}
+ </Container>
+ </div>
+ );
+};
+
+export default Timeline;
diff --git a/Timeline/ClientApp/src/timeline/TimelineDeleteDialog.tsx b/Timeline/ClientApp/src/timeline/TimelineDeleteDialog.tsx new file mode 100644 index 00000000..2b682a6b --- /dev/null +++ b/Timeline/ClientApp/src/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 { useUser } from '../data/user';
+import OperationDialog from '../common/OperationDialog';
+
+interface TimelineDeleteDialog {
+ open: boolean;
+ name: string;
+ close: () => void;
+}
+
+const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = props => {
+ const user = useUser()!;
+ 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/timeline/TimelineInfoCard.tsx b/Timeline/ClientApp/src/timeline/TimelineInfoCard.tsx new file mode 100644 index 00000000..2ce7c378 --- /dev/null +++ b/Timeline/ClientApp/src/timeline/TimelineInfoCard.tsx @@ -0,0 +1,112 @@ +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();
+
+ 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),
+ []
+ );
+ const onManageProperty = React.useCallback(
+ (): void => onManage!('property'),
+ [onManage]
+ );
+ const onManageDelete = React.useCallback((): void => onManage!('delete'), [
+ onManage,
+ ]);
+
+ 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">
+ {props.onManage != null ? (
+ <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}>
+ <DropdownToggle outline color="primary">
+ {t('timeline.manage')}
+ </DropdownToggle>
+ <DropdownMenu>
+ <DropdownItem onClick={onManageProperty}>
+ {t('timeline.manageItem.property')}
+ </DropdownItem>
+ <DropdownItem onClick={props.onMember}>
+ {t('timeline.manageItem.member')}
+ </DropdownItem>
+ <DropdownItem divider />
+ <DropdownItem className="text-danger" onClick={onManageDelete}>
+ {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/timeline/TimelineItem.tsx b/Timeline/ClientApp/src/timeline/TimelineItem.tsx new file mode 100644 index 00000000..402d51d9 --- /dev/null +++ b/Timeline/ClientApp/src/timeline/TimelineItem.tsx @@ -0,0 +1,113 @@ +import React from 'react';
+import { useTranslation } from 'react-i18next';
+import clsx from 'clsx';
+import { Row, Col } from 'reactstrap';
+import { Link } from 'react-router-dom';
+
+import { TimelinePostInfo } from '../data/timeline';
+import { useAvatarUrlWithGivenVersion } from '../user/api';
+
+export interface TimelineItemProps {
+ post: TimelinePostInfo;
+ showDeleteButton?: boolean;
+ current?: boolean;
+ toggleMore?: () => void;
+ onDelete?: () => void;
+ onClick?: () => void;
+ avatarVersion?: number;
+}
+
+const TimelineItem: React.FC<TimelineItemProps> = (props) => {
+ const { i18n } = useTranslation();
+
+ const current = props.current === true;
+
+ const { toggleMore: toggleDelete } = props;
+
+ const avatarUrl = useAvatarUrlWithGivenVersion(
+ props.avatarVersion,
+ props.post.author._links.avatar
+ );
+
+ const onOpenMore = React.useMemo<
+ React.MouseEventHandler<HTMLElement> | undefined
+ >(() => {
+ if (toggleDelete == null) {
+ return undefined;
+ } else {
+ return (e) => {
+ toggleDelete();
+ e.stopPropagation();
+ };
+ }
+ }, [toggleDelete]);
+
+ return (
+ <Row
+ className={clsx('position-relative flex-nowrap', current && 'current')}
+ onClick={props.onClick}
+ >
+ <Col className="timeline-line-area">
+ <div className="timeline-line start"></div>
+ <div className="timeline-line-node-container">
+ <div className="timeline-line-node"></div>
+ </div>
+ <div className="timeline-line end"></div>
+ {current && <div className="timeline-line 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>
+ {props.toggleMore != 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={onOpenMore}
+ />
+ </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 src={avatarUrl} className="avatar rounded" />
+ </Link>
+ {(() => {
+ const { content } = props.post;
+ if (content.type === 'text') {
+ return content.text;
+ } else {
+ return (
+ <img src={content.url} className="timeline-content-image" />
+ );
+ }
+ })()}
+ </p>
+ </Col>
+ {props.showDeleteButton ? (
+ <div
+ className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center"
+ onClick={props.toggleMore}
+ >
+ <i
+ className="fas fa-trash text-danger large-icon"
+ onClick={props.onDelete}
+ />
+ </div>
+ ) : undefined}
+ </Row>
+ );
+};
+
+export default TimelineItem;
diff --git a/Timeline/ClientApp/src/timeline/TimelineMember.tsx b/Timeline/ClientApp/src/timeline/TimelineMember.tsx new file mode 100644 index 00000000..eac8d417 --- /dev/null +++ b/Timeline/ClientApp/src/timeline/TimelineMember.tsx @@ -0,0 +1,194 @@ +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.toString()
+ });
+ }
+ );
+ }}
+ />
+ {(() => {
+ 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={() => {
+ 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/timeline/TimelinePage.tsx b/Timeline/ClientApp/src/timeline/TimelinePage.tsx new file mode 100644 index 00000000..5adebe1f --- /dev/null +++ b/Timeline/ClientApp/src/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/timeline/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/timeline/TimelinePageTemplate.tsx new file mode 100644 index 00000000..3660ad78 --- /dev/null +++ b/Timeline/ClientApp/src/timeline/TimelinePageTemplate.tsx @@ -0,0 +1,271 @@ +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';
+
+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.toString());
+ }
+ }
+ );
+ }
+ }
+ },
+ (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]);
+
+ React.useEffect(() => {
+ if (posts != null) {
+ window.scrollTo(
+ 0,
+ document.body.scrollHeight || document.documentElement.scrollHeight
+ );
+ }
+ }, [posts]);
+
+ const closeDialog = React.useCallback((): void => {
+ setDialog(null);
+ }, []);
+
+ let dialogElement: React.ReactElement | undefined;
+
+ if (dialog === 'property') {
+ 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') {
+ 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) => {
+ 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/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/timeline/TimelinePageTemplateUI.tsx new file mode 100644 index 00000000..d96b3260 --- /dev/null +++ b/Timeline/ClientApp/src/timeline/TimelinePageTemplateUI.tsx @@ -0,0 +1,147 @@ +import React from 'react';
+import { Spinner } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+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, 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, TEditItems>(
+ props: TimelinePageTemplateUIProps<TTimeline, TEditItems>
+): React.ReactElement | null {
+ 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 [cardHeight, setCardHeight] = React.useState<number>(0);
+
+ const onCardHeightChange = React.useCallback((height: number) => {
+ setCardHeight(height);
+ }, []);
+
+ const [infoCardCollapse, setInfoCardCollapse] = React.useState<boolean>(
+ false
+ );
+ const toggleInfoCardCollapse = React.useCallback((collapse) => {
+ setInfoCardCollapse(collapse);
+ }, []);
+
+ let body: React.ReactElement;
+
+ if (props.error != null) {
+ body = <p className="text-danger">{t(props.error)}</p>;
+ } else {
+ if (props.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} />
+ );
+ if (props.onPost != null) {
+ timelineBody = (
+ <>
+ {timelineBody}
+ <div ref={bottomSpaceRef} className="flex-fix-length" />
+ <TimelinePostEdit
+ onPost={props.onPost}
+ onHeightChange={onPostEditHeightChange}
+ />
+ </>
+ );
+ }
+ }
+ } else {
+ timelineBody = <Spinner />;
+ }
+ const { CardComponent } = props;
+
+ body = (
+ <>
+ <div
+ className="fixed-top mt-appbar info-card-container"
+ data-collapse={infoCardCollapse ? 'true' : 'false'}
+ >
+ <CollapseButton
+ collapse={infoCardCollapse}
+ toggle={toggleInfoCardCollapse}
+ className="float-right m-1 info-card-collapse-button"
+ />
+ <CardComponent
+ timeline={props.timeline}
+ onManage={props.onManage}
+ onMember={props.onMember}
+ onHeight={onCardHeightChange}
+ className="info-card-content"
+ />
+ </div>
+ {timelineBody}
+ </>
+ );
+ } else {
+ body = <Spinner />;
+ }
+ }
+
+ return (
+ <>
+ <AppBar />
+ <div
+ style={{ marginTop: 56 + cardHeight }}
+ className="timeline-page-container"
+ >
+ {body}
+ </div>
+ </>
+ );
+}
diff --git a/Timeline/ClientApp/src/timeline/TimelinePageUI.tsx b/Timeline/ClientApp/src/timeline/TimelinePageUI.tsx new file mode 100644 index 00000000..01a230af --- /dev/null +++ b/Timeline/ClientApp/src/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/timeline/TimelinePostEdit.tsx b/Timeline/ClientApp/src/timeline/TimelinePostEdit.tsx new file mode 100644 index 00000000..fe1fda9b --- /dev/null +++ b/Timeline/ClientApp/src/timeline/TimelinePostEdit.tsx @@ -0,0 +1,205 @@ +import React from 'react';
+import clsx from 'clsx';
+import { Container, 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';
+
+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;
+}
+
+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 canSend = kind === 'text' || (kind === 'image' && imageBlob != null);
+
+ React.useEffect(() => {
+ if (props.onHeightChange) {
+ props.onHeightChange(
+ document.getElementById('timeline-post-edit-area')!.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 =
+ kind === 'text'
+ ? {
+ content: {
+ type: 'text',
+ text: text,
+ },
+ }
+ : {
+ content: {
+ type: 'image',
+ data: imageBlob!,
+ },
+ };
+
+ onPost(req).then(
+ (_) => {
+ if (kind === 'text') {
+ setText('');
+ }
+ setState('input');
+ setKind('text');
+ },
+ (_) => {
+ pushAlert({
+ type: 'danger',
+ message: t('timeline.sendPostFailed'),
+ });
+ setState('input');
+ }
+ );
+ }, [onPost, kind, text, imageBlob, t]);
+
+ const onImageSelect = React.useCallback((blob: Blob | null) => {
+ setImageBlob(blob);
+ }, []);
+
+ return (
+ <Container
+ id="timeline-post-edit-area"
+ fluid
+ className="fixed-bottom bg-light"
+ >
+ <Row>
+ <Col className="px-0">
+ {kind === 'text' ? (
+ <textarea
+ className="w-100 h-100"
+ value={text}
+ disabled={state === 'process'}
+ onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
+ setText(event.currentTarget.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>
+ </Container>
+ );
+};
+
+export default TimelinePostEdit;
diff --git a/Timeline/ClientApp/src/timeline/TimelinePropertyChangeDialog.tsx b/Timeline/ClientApp/src/timeline/TimelinePropertyChangeDialog.tsx new file mode 100644 index 00000000..9ab725a6 --- /dev/null +++ b/Timeline/ClientApp/src/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/timeline/timeline-ui.scss b/Timeline/ClientApp/src/timeline/timeline-ui.scss new file mode 100644 index 00000000..ef35ff55 --- /dev/null +++ b/Timeline/ClientApp/src/timeline/timeline-ui.scss @@ -0,0 +1,27 @@ +/*
+.info-card-container {
+}
+
+.info-card-container[data-collapse='true'] {
+}
+*/
+
+.info-card-container .info-card-collapse-button {
+ z-index: 1;
+ position: relative;
+}
+
+.info-card-container .info-card-content {
+ width: 100%;
+ position: absolute;
+ transform-origin: right top;
+ transition: transform 0.5s;
+}
+
+.info-card-container[data-collapse='true'] .info-card-content {
+ transform: scale(0);
+}
+
+.timeline-page-container {
+ transition: margin-top 0.5s;
+}
diff --git a/Timeline/ClientApp/src/timeline/timeline.scss b/Timeline/ClientApp/src/timeline/timeline.scss new file mode 100644 index 00000000..e82b376b --- /dev/null +++ b/Timeline/ClientApp/src/timeline/timeline.scss @@ -0,0 +1,119 @@ +@use 'sass:color';
+
+$timeline-line-width: 7px;
+$timeline-line-node-radius: 18px;
+$timeline-line-color: $primary;
+$timeline-line-color-current: #36c2e6;
+
+.timeline-line-area {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ flex: 0 0 auto;
+ width: 60px;
+}
+
+.timeline-line {
+ width: $timeline-line-width;
+ background: $timeline-line-color;
+}
+
+.timeline-line.start {
+ height: 20px;
+ flex: 0 0 auto;
+}
+
+.timeline-pt-start {
+ padding-top: 18px;
+}
+
+.timeline-line.end {
+ flex: 1 1 auto;
+}
+
+@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-node-container {
+ flex: 0 0 auto;
+ position: relative;
+ width: $timeline-line-node-radius;
+ height: $timeline-line-node-radius;
+}
+
+.timeline-line-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;
+}
+
+.timeline-line.current-end {
+ height: 20px;
+ flex: 0 0 auto;
+ background: linear-gradient($timeline-line-color-current, transparent);
+}
+
+.current {
+ .timeline-line.start {
+ background: linear-gradient(
+ $timeline-line-color,
+ $timeline-line-color-current
+ );
+ }
+ .timeline-line-node {
+ animation-name: timeline-line-node-current;
+ }
+ .timeline-line.end {
+ background: $timeline-line-color-current;
+ }
+}
+
+.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;
+}
|