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.tsx102
-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.tsx181
-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.tsx243
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx22
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx234
-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, 0 insertions, 1673 deletions
diff --git a/Timeline/ClientApp/src/app/timeline/Timeline.tsx b/Timeline/ClientApp/src/app/timeline/Timeline.tsx
deleted file mode 100644
index 0a68c5db..00000000
--- a/Timeline/ClientApp/src/app/timeline/Timeline.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import React from 'react';
-import clsx from 'clsx';
-
-import { TimelinePostInfo } from '../data/timeline';
-import { useUser } from '../data/user';
-import { useAvatarVersion } from '../user/api';
-
-import TimelineItem from './TimelineItem';
-
-export interface TimelinePostInfoEx extends TimelinePostInfo {
- deletable: boolean;
-}
-
-export type TimelineDeleteCallback = (index: number, id: number) => void;
-
-export interface TimelineProps {
- className?: string;
- posts: TimelinePostInfoEx[];
- onDelete: TimelineDeleteCallback;
- onResize?: () => void;
- containerRef?: React.Ref<HTMLDivElement>;
-}
-
-const Timeline: React.FC<TimelineProps> = (props) => {
- const user = useUser();
- const avatarVersion = useAvatarVersion();
-
- const { posts, onDelete, onResize } = props;
-
- const [indexShowDeleteButton, setIndexShowDeleteButton] = React.useState<
- number
- >(-1);
-
- const onItemClick = React.useCallback(() => {
- setIndexShowDeleteButton(-1);
- }, []);
-
- const onToggleDelete = React.useMemo(() => {
- return posts.map((post, i) => {
- return post.deletable
- ? () => {
- setIndexShowDeleteButton((oldIndexShowDeleteButton) => {
- return oldIndexShowDeleteButton !== i ? i : -1;
- });
- }
- : undefined;
- });
- }, [posts]);
-
- const onItemDelete = React.useMemo(() => {
- return posts.map((post, i) => {
- return () => {
- onDelete(i, post.id);
- };
- });
- }, [posts, onDelete]);
-
- return (
- <div
- ref={props.containerRef}
- className={clsx(
- 'container-fluid d-flex flex-column position-relative',
- props.className
- )}
- >
- <div className="timeline-enter-animation-mask" />
- {(() => {
- const length = posts.length;
- return posts.map((post, i) => {
- const av: number | undefined =
- user != null && user.username === post.author.username
- ? avatarVersion
- : undefined;
-
- const toggleMore = onToggleDelete[i];
-
- return (
- <TimelineItem
- post={post}
- key={post.id}
- current={length - 1 === i}
- more={
- toggleMore
- ? {
- isOpen: indexShowDeleteButton === i,
- toggle: toggleMore,
- onDelete: onItemDelete[i],
- }
- : undefined
- }
- onClick={onItemClick}
- avatarVersion={av}
- onResize={onResize}
- />
- );
- });
- })()}
- </div>
- );
-};
-
-export default Timeline;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx
deleted file mode 100644
index 5e7c6dd4..00000000
--- a/Timeline/ClientApp/src/app/timeline/TimelineDeleteDialog.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import React from 'react';
-import axios from 'axios';
-import { useHistory } from 'react-router';
-import { Trans } from 'react-i18next';
-
-import { apiBaseUrl } from '../config';
-import { useUserLoggedIn } from '../data/user';
-import OperationDialog from '../common/OperationDialog';
-
-interface TimelineDeleteDialog {
- open: boolean;
- name: string;
- close: () => void;
-}
-
-const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
- const user = useUserLoggedIn();
- const history = useHistory();
-
- const { name } = props;
-
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- title="timeline.deleteDialog.title"
- titleColor="danger"
- inputPrompt={() => {
- return (
- <Trans i18nKey="timeline.deleteDialog.inputPrompt">
- 0<code className="mx-2">{{ name }}</code>2
- </Trans>
- );
- }}
- inputScheme={[
- {
- type: 'text',
- validator: (value) => {
- if (value !== name) {
- return 'timeline.deleteDialog.notMatch';
- } else {
- return null;
- }
- },
- },
- ]}
- onProcess={() => {
- return axios.delete(
- `${apiBaseUrl}/timelines/${name}?token=${user.token}`
- );
- }}
- onSuccessAndClose={() => {
- history.replace('/');
- }}
- />
- );
-};
-
-export default TimelineDeleteDialog;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx
deleted file mode 100644
index e4bc07d1..00000000
--- a/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import React from 'react';
-import clsx from 'clsx';
-import {
- Dropdown,
- DropdownToggle,
- DropdownMenu,
- DropdownItem,
- Button,
-} from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import { fromEvent } from 'rxjs';
-
-import {
- timelineVisibilityTooltipTranslationMap,
- TimelineInfo,
-} from '../data/timeline';
-import { TimelineCardComponentProps } from './TimelinePageTemplateUI';
-
-export type OrdinaryTimelineManageItem = 'delete';
-
-export type TimelineInfoCardProps = TimelineCardComponentProps<
- TimelineInfo,
- OrdinaryTimelineManageItem
->;
-
-const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
- const { onHeight, onManage } = props;
-
- const { t } = useTranslation();
-
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const containerRef = React.useRef<HTMLDivElement>(null!);
-
- const notifyHeight = React.useCallback((): void => {
- if (onHeight) {
- onHeight(containerRef.current.getBoundingClientRect().height);
- }
- }, [onHeight]);
-
- React.useEffect(() => {
- const subscription = fromEvent(window, 'resize').subscribe(notifyHeight);
- return () => subscription.unsubscribe();
- });
-
- const [manageDropdownOpen, setManageDropdownOpen] = React.useState<boolean>(
- false
- );
- const toggleManageDropdown = React.useCallback(
- (): void => setManageDropdownOpen((old) => !old),
- []
- );
-
- return (
- <div
- ref={containerRef}
- className={clsx('rounded border p-2 bg-light', props.className)}
- onTransitionEnd={notifyHeight}
- >
- <h3 className="text-primary mx-3 d-inline-block align-middle">
- {props.timeline.name}
- </h3>
- <div className="d-inline-block align-middle">
- <img
- src={props.timeline.owner._links.avatar}
- onLoad={notifyHeight}
- className="avatar small rounded-circle"
- />
- {props.timeline.owner.nickname}
- <small className="ml-3 text-secondary">
- @{props.timeline.owner.username}
- </small>
- </div>
- <p className="mb-0">{props.timeline.description}</p>
- <small className="mt-1 d-block">
- {t(timelineVisibilityTooltipTranslationMap[props.timeline.visibility])}
- </small>
- <div className="text-right mt-2">
- {onManage != null ? (
- <Dropdown isOpen={manageDropdownOpen} toggle={toggleManageDropdown}>
- <DropdownToggle outline color="primary">
- {t('timeline.manage')}
- </DropdownToggle>
- <DropdownMenu>
- <DropdownItem onClick={() => onManage('property')}>
- {t('timeline.manageItem.property')}
- </DropdownItem>
- <DropdownItem onClick={props.onMember}>
- {t('timeline.manageItem.member')}
- </DropdownItem>
- <DropdownItem divider />
- <DropdownItem
- className="text-danger"
- onClick={() => onManage('delete')}
- >
- {t('timeline.manageItem.delete')}
- </DropdownItem>
- </DropdownMenu>
- </Dropdown>
- ) : (
- <Button color="primary" outline onClick={props.onMember}>
- {t('timeline.memberButton')}
- </Button>
- )}
- </div>
- </div>
- );
-};
-
-export default TimelineInfoCard;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx
deleted file mode 100644
index 43e206f1..00000000
--- a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import React from 'react';
-import clsx from 'clsx';
-import {
- Row,
- Col,
- Modal,
- ModalHeader,
- ModalBody,
- ModalFooter,
- Button,
-} from 'reactstrap';
-import { Link } from 'react-router-dom';
-import { useTranslation } from 'react-i18next';
-import Svg from 'react-inlinesvg';
-
-import chevronDownIcon from 'bootstrap-icons/icons/chevron-down.svg';
-import trashIcon from 'bootstrap-icons/icons/trash.svg';
-
-import { TimelinePostInfo } from '../data/timeline';
-import { useAvatarUrlWithGivenVersion } from '../user/api';
-
-const TimelinePostDeleteConfirmDialog: React.FC<{
- toggle: () => void;
- onConfirm: () => void;
-}> = ({ toggle, onConfirm }) => {
- const { t } = useTranslation();
-
- return (
- <Modal toggle={toggle} isOpen centered>
- <ModalHeader className="text-danger">
- {t('timeline.post.deleteDialog.title')}
- </ModalHeader>
- <ModalBody>{t('timeline.post.deleteDialog.prompt')}</ModalBody>
- <ModalFooter>
- <Button color="secondary" onClick={toggle}>
- {t('operationDialog.cancel')}
- </Button>
- <Button
- color="danger"
- onClick={() => {
- onConfirm();
- toggle();
- }}
- >
- {t('operationDialog.confirm')}
- </Button>
- </ModalFooter>
- </Modal>
- );
-};
-
-export interface TimelineItemProps {
- post: TimelinePostInfo;
- current?: boolean;
- more?: {
- isOpen: boolean;
- toggle: () => void;
- onDelete: () => void;
- };
- onClick?: () => void;
- avatarVersion?: number;
- onResize?: () => void;
- className?: string;
- style?: React.CSSProperties;
-}
-
-const TimelineItem: React.FC<TimelineItemProps> = (props) => {
- const { i18n } = useTranslation();
-
- const current = props.current === true;
-
- const { more, onResize } = props;
-
- const avatarUrl = useAvatarUrlWithGivenVersion(
- props.avatarVersion,
- props.post.author._links.avatar
- );
-
- const [deleteDialog, setDeleteDialog] = React.useState<boolean>(false);
- const toggleDeleteDialog = React.useCallback(
- () => setDeleteDialog((old) => !old),
- []
- );
-
- return (
- <Row
- className={clsx(
- 'position-relative flex-nowrap',
- current && 'current',
- props.className
- )}
- onClick={props.onClick}
- style={props.style}
- >
- <Col className="timeline-line-area">
- <div className="timeline-line-segment start"></div>
- <div className="timeline-line-node-container">
- <div className="timeline-line-node"></div>
- </div>
- <div className="timeline-line-segment end"></div>
- {current && <div className="timeline-line-segment current-end" />}
- </Col>
- <Col className="timeline-pt-start">
- <Row className="flex-nowrap">
- <div className="col-auto flex-shrink-1 px-0">
- <Row className="ml-n3 mr-0 align-items-center">
- <span className="ml-3 text-primary white-space-no-wrap">
- {props.post.time.toLocaleString(i18n.languages)}
- </span>
- <small className="text-dark ml-3">
- {props.post.author.nickname}
- </small>
- </Row>
- </div>
- {more != null ? (
- <div className="col-auto px-2 d-flex justify-content-center align-items-center">
- <Svg
- src={chevronDownIcon}
- className="text-info icon-button"
- onClick={(e: Event) => {
- more.toggle();
- e.stopPropagation();
- }}
- />
- </div>
- ) : null}
- </Row>
- <div className="row d-block timeline-content">
- <Link
- className="float-right float-sm-left mx-2"
- to={'/users/' + props.post.author.username}
- >
- <img onLoad={onResize} src={avatarUrl} className="avatar rounded" />
- </Link>
- {(() => {
- const { content } = props.post;
- if (content.type === 'text') {
- return content.text;
- } else {
- return (
- <img
- onLoad={onResize}
- src={content.url}
- className="timeline-content-image"
- />
- );
- }
- })()}
- </div>
- </Col>
- {more != null && more.isOpen ? (
- <>
- <div
- className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center"
- onClick={more.toggle}
- >
- <Svg
- src={trashIcon}
- className="text-danger large-icon-button"
- onClick={(e: Event) => {
- toggleDeleteDialog();
- e.stopPropagation();
- }}
- />
- </div>
- {deleteDialog ? (
- <TimelinePostDeleteConfirmDialog
- toggle={() => {
- toggleDeleteDialog();
- more.toggle();
- }}
- onConfirm={more.onDelete}
- />
- ) : null}
- </>
- ) : null}
- </Row>
- );
-};
-
-export default TimelineItem;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx
deleted file mode 100644
index f9747b4d..00000000
--- a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-import React, { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-
-import { User } from '../data/user';
-
-import SearchInput from '../common/SearchInput';
-import {
- Container,
- ListGroup,
- ListGroupItem,
- Modal,
- Row,
- Col,
- Button,
-} from 'reactstrap';
-
-export interface TimelineMemberCallbacks {
- onCheckUser: (username: string) => Promise<User | null>;
- onAddUser: (user: User) => Promise<void>;
- onRemoveUser: (username: string) => void;
-}
-
-export interface TimelineMemberProps {
- members: User[];
- edit: TimelineMemberCallbacks | null | undefined;
-}
-
-const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
- const { t } = useTranslation();
-
- const [userSearchText, setUserSearchText] = useState<string>('');
- const [userSearchState, setUserSearchState] = useState<
- | {
- type: 'user';
- data: User;
- }
- | { type: 'error'; data: string }
- | { type: 'loading' }
- | { type: 'init' }
- >({ type: 'init' });
-
- const members = props.members;
-
- return (
- <Container className="px-4">
- <ListGroup className="my-3">
- {members.map((member, index) => (
- <ListGroupItem key={member.username} className="container">
- <Row>
- <Col className="col-auto">
- <img src={member._links.avatar} className="avatar small" />
- </Col>
- <Col>
- <Row>{member.nickname}</Row>
- <Row>
- <small>{'@' + member.username}</small>
- </Row>
- </Col>
- {(() => {
- if (index === 0) {
- return null;
- }
- const onRemove = props.edit?.onRemoveUser;
- if (onRemove == null) {
- return null;
- }
- return (
- <Button
- className="align-self-center"
- color="danger"
- onClick={() => {
- onRemove(member.username);
- }}
- >
- {t('timeline.member.remove')}
- </Button>
- );
- })()}
- </Row>
- </ListGroupItem>
- ))}
- </ListGroup>
- {(() => {
- const edit = props.edit;
- if (edit != null) {
- return (
- <>
- <SearchInput
- value={userSearchText}
- onChange={(v) => {
- setUserSearchText(v);
- }}
- loading={userSearchState.type === 'loading'}
- onButtonClick={() => {
- if (userSearchText === '') {
- setUserSearchState({
- type: 'error',
- data: 'login.emptyUsername',
- });
- return;
- }
-
- setUserSearchState({ type: 'loading' });
- edit.onCheckUser(userSearchText).then(
- (u) => {
- if (u == null) {
- setUserSearchState({
- type: 'error',
- data: 'timeline.userNotExist',
- });
- } else {
- setUserSearchState({ type: 'user', data: u });
- }
- },
- (e) => {
- setUserSearchState({
- type: 'error',
- data: `${e as string}`,
- });
- }
- );
- }}
- />
- {(() => {
- if (userSearchState.type === 'user') {
- const u = userSearchState.data;
- const addable =
- members.findIndex((m) => m.username === u.username) === -1;
- return (
- <>
- {!addable ? (
- <p>{t('timeline.member.alreadyMember')}</p>
- ) : null}
- <Container className="mb-3">
- <Row>
- <Col className="col-auto">
- <img
- src={u._links.avatar}
- className="avatar small"
- />
- </Col>
- <Col>
- <Row>{u.nickname}</Row>
- <Row>
- <small>{'@' + u.username}</small>
- </Row>
- </Col>
- <Button
- color="primary"
- className="align-self-center"
- disabled={!addable}
- onClick={() => {
- void edit.onAddUser(u).then((_) => {
- setUserSearchText('');
- setUserSearchState({ type: 'init' });
- });
- }}
- >
- {t('timeline.member.add')}
- </Button>
- </Row>
- </Container>
- </>
- );
- } else if (userSearchState.type === 'error') {
- return (
- <p className="text-danger">{t(userSearchState.data)}</p>
- );
- }
- })()}
- </>
- );
- } else {
- return null;
- }
- })()}
- </Container>
- );
-};
-
-export default TimelineMember;
-
-export interface TimelineMemberDialogProps extends TimelineMemberProps {
- open: boolean;
- onClose: () => void;
-}
-
-export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = (
- props
-) => {
- return (
- <Modal isOpen={props.open} toggle={props.onClose}>
- <TimelineMember {...props} />
- </Modal>
- );
-};
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx
deleted file mode 100644
index 900d6e6a..00000000
--- a/Timeline/ClientApp/src/app/timeline/TimelinePage.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import React from 'react';
-import { useParams } from 'react-router';
-
-import { ordinaryTimelineService } from '../data/timeline';
-
-import TimelinePageUI from './TimelinePageUI';
-import TimelinePageTemplate from '../timeline/TimelinePageTemplate';
-import { OrdinaryTimelineManageItem } from './TimelineInfoCard';
-import TimelineDeleteDialog from './TimelineDeleteDialog';
-
-const TimelinePage: React.FC = _ => {
- const { name } = useParams<{ name: string }>();
-
- const [dialog, setDialog] = React.useState<OrdinaryTimelineManageItem | null>(
- null
- );
-
- let dialogElement: React.ReactElement | undefined;
- if (dialog === 'delete') {
- dialogElement = (
- <TimelineDeleteDialog open close={() => setDialog(null)} name={name} />
- );
- }
-
- return (
- <>
- <TimelinePageTemplate
- name={name}
- UiComponent={TimelinePageUI}
- onManage={item => setDialog(item)}
- service={ordinaryTimelineService}
- notFoundI18nKey="timeline.timelineNotExist"
- />
- {dialogElement}
- </>
- );
-};
-
-export default TimelinePage;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx
deleted file mode 100644
index 38ecd8f9..00000000
--- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx
+++ /dev/null
@@ -1,275 +0,0 @@
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-import { AxiosError } from 'axios';
-import concat from 'lodash/concat';
-import without from 'lodash/without';
-
-import { ExcludeKey } from '../type-utilities';
-import { useUser, fetchUser } from '../data/user';
-import { pushAlert } from '../common/alert-service';
-import { extractStatusCode, extractErrorCode } from '../data/common';
-import {
- TimelineServiceTemplate,
- TimelineInfo,
- TimelineChangePropertyRequest,
-} from '../data/timeline';
-
-import { TimelinePostInfoEx, TimelineDeleteCallback } from './Timeline';
-import { TimelineMemberDialog } from './TimelineMember';
-import TimelinePropertyChangeDialog from './TimelinePropertyChangeDialog';
-import { TimelinePageTemplateUIProps } from './TimelinePageTemplateUI';
-import { TimelinePostSendCallback } from './TimelinePostEdit';
-import { UiLogicError } from '../common';
-
-export interface TimelinePageTemplateProps<
- TManageItem,
- TTimeline extends TimelineInfo
-> {
- name: string;
- onManage: (item: TManageItem) => void;
- service: TimelineServiceTemplate<TTimeline, TimelineChangePropertyRequest>;
- UiComponent: React.ComponentType<
- ExcludeKey<
- TimelinePageTemplateUIProps<TTimeline, TManageItem>,
- 'CardComponent'
- >
- >;
- dataVersion?: number;
- notFoundI18nKey: string;
-}
-
-export default function TimelinePageTemplate<
- TManageItem,
- TTimeline extends TimelineInfo
->(
- props: TimelinePageTemplateProps<TManageItem, TTimeline>
-): React.ReactElement | null {
- const { t } = useTranslation();
-
- const { name } = props;
-
- const user = useUser();
-
- const [dialog, setDialog] = React.useState<null | 'property' | 'member'>(
- null
- );
- const [timeline, setTimeline] = React.useState<TTimeline | undefined>(
- undefined
- );
- const [posts, setPosts] = React.useState<
- TimelinePostInfoEx[] | 'forbid' | undefined
- >(undefined);
- const [error, setError] = React.useState<string | undefined>(undefined);
-
- const service = props.service;
-
- React.useEffect(() => {
- let subscribe = true;
- service.fetch(name).then(
- (ti) => {
- if (subscribe) {
- setTimeline(ti);
- if (!service.hasReadPermission(user, ti)) {
- setPosts('forbid');
- } else {
- service.fetchPosts(name).then(
- (data) => {
- if (subscribe) {
- setPosts(
- data.map((post) => ({
- ...post,
- deletable: service.hasModifyPostPermission(
- user,
- ti,
- post
- ),
- }))
- );
- }
- },
- (error) => {
- if (subscribe) {
- setError(`${error as string}`);
- }
- }
- );
- }
- }
- },
- (error: AxiosError) => {
- if (subscribe) {
- if (
- extractStatusCode(error) === 404 ||
- extractErrorCode(error) === 11020101
- ) {
- setError(t(props.notFoundI18nKey));
- } else {
- setError(error.toString());
- }
- }
- }
- );
- return () => {
- subscribe = false;
- };
- }, [name, service, user, t, props.dataVersion, props.notFoundI18nKey]);
-
- const closeDialog = React.useCallback((): void => {
- setDialog(null);
- }, []);
-
- let dialogElement: React.ReactElement | undefined;
-
- if (dialog === 'property') {
- if (timeline == null) {
- throw new UiLogicError(
- 'Timeline is null but attempt to open change property dialog.'
- );
- }
-
- dialogElement = (
- <TimelinePropertyChangeDialog
- open
- close={closeDialog}
- oldInfo={{
- visibility: timeline.visibility,
- description: timeline.description,
- }}
- onProcess={(req) => {
- return service.changeProperty(name, req).then((newTimeline) => {
- setTimeline(newTimeline);
- });
- }}
- />
- );
- } else if (dialog === 'member') {
- if (timeline == null) {
- throw new UiLogicError(
- 'Timeline is null but attempt to open change property dialog.'
- );
- }
-
- dialogElement = (
- <TimelineMemberDialog
- open
- onClose={closeDialog}
- members={[timeline.owner, ...timeline.members]}
- edit={
- service.hasManagePermission(user, timeline)
- ? {
- onCheckUser: (u) => {
- return fetchUser(u).catch((e) => {
- if (
- extractStatusCode(e) === 404 ||
- extractErrorCode(e) === 11020101
- ) {
- return Promise.resolve(null);
- } else {
- return Promise.reject(e);
- }
- });
- },
- onAddUser: (u) => {
- return service.addMember(name, u.username).then((_) => {
- setTimeline({
- ...timeline,
- members: concat(timeline.members, u),
- });
- });
- },
- onRemoveUser: (u) => {
- void service.removeMember(name, u).then((_) => {
- setTimeline({
- ...timeline,
- members: without(
- timeline.members,
- timeline.members.find((m) => m.username === u)
- ),
- });
- });
- },
- }
- : null
- }
- />
- );
- }
-
- const { UiComponent } = props;
-
- const onDelete: TimelineDeleteCallback = React.useCallback(
- (index, id) => {
- service.deletePost(name, id).then(
- (_) => {
- setPosts((oldPosts) =>
- without(
- oldPosts as TimelinePostInfoEx[],
- (oldPosts as TimelinePostInfoEx[])[index]
- )
- );
- },
- () => {
- pushAlert({
- type: 'danger',
- message: t('timeline.deletePostFailed'),
- });
- }
- );
- },
- [service, name, t]
- );
-
- const onPost: TimelinePostSendCallback = React.useCallback(
- (req) => {
- return service.createPost(name, req).then((newPost) => {
- setPosts((oldPosts) =>
- concat(oldPosts as TimelinePostInfoEx[], {
- ...newPost,
- deletable: true,
- })
- );
- });
- },
- [service, name]
- );
-
- const onManageProp = props.onManage;
-
- const onManage = React.useCallback(
- (item: 'property' | TManageItem) => {
- if (item === 'property') {
- setDialog(item);
- } else {
- onManageProp(item);
- }
- },
- [onManageProp]
- );
-
- const onMember = React.useCallback(() => {
- setDialog('member');
- }, []);
-
- return (
- <>
- <UiComponent
- error={error}
- timeline={timeline}
- posts={posts}
- onDelete={onDelete}
- onPost={
- timeline != null && service.hasPostPermission(user, timeline)
- ? onPost
- : undefined
- }
- onManage={
- timeline != null && service.hasManagePermission(user, timeline)
- ? onManage
- : undefined
- }
- onMember={onMember}
- />
- {dialogElement}
- </>
- );
-}
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx
deleted file mode 100644
index 924e7883..00000000
--- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-import React from 'react';
-import { Spinner } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import { fromEvent } from 'rxjs';
-import Svg from 'react-inlinesvg';
-
-import arrowsAngleContractIcon from 'bootstrap-icons/icons/arrows-angle-contract.svg';
-import arrowsAngleExpandIcon from 'bootstrap-icons/icons/arrows-angle-expand.svg';
-
-import { getAlertHost } from '../common/alert-service';
-
-import Timeline, {
- TimelinePostInfoEx,
- TimelineDeleteCallback,
-} from './Timeline';
-import AppBar from '../common/AppBar';
-import TimelinePostEdit, { TimelinePostSendCallback } from './TimelinePostEdit';
-import { useEventEmiiter } from '../common';
-
-export interface TimelineCardComponentProps<TTimeline, TManageItems> {
- timeline: TTimeline;
- onManage?: (item: TManageItems | 'property') => void;
- onMember: () => void;
- className?: string;
- onHeight?: (height: number) => void;
-}
-
-export interface TimelinePageTemplateUIProps<
- TTimeline extends { uniqueId: string },
- TManageItems
-> {
- avatarKey?: string | number;
- timeline?: TTimeline;
- posts?: TimelinePostInfoEx[] | 'forbid';
- CardComponent: React.ComponentType<
- TimelineCardComponentProps<TTimeline, TManageItems>
- >;
- onMember: () => void;
- onManage?: (item: TManageItems | 'property') => void;
- onPost?: TimelinePostSendCallback;
- onDelete: TimelineDeleteCallback;
- error?: string;
-}
-
-export default function TimelinePageTemplateUI<
- TTimeline extends { uniqueId: string },
- TEditItems
->(
- props: TimelinePageTemplateUIProps<TTimeline, TEditItems>
-): React.ReactElement | null {
- const { timeline } = props;
-
- const { t } = useTranslation();
-
- const bottomSpaceRef = React.useRef<HTMLDivElement | null>(null);
-
- const onPostEditHeightChange = React.useCallback((height: number): void => {
- const { current: bottomSpaceDiv } = bottomSpaceRef;
- if (bottomSpaceDiv != null) {
- bottomSpaceDiv.style.height = `${height}px`;
- }
- if (height === 0) {
- const alertHost = getAlertHost();
- if (alertHost != null) {
- alertHost.style.removeProperty('margin-bottom');
- }
- } else {
- const alertHost = getAlertHost();
- if (alertHost != null) {
- alertHost.style.marginBottom = `${height}px`;
- }
- }
- }, []);
-
- const timelineRef = React.useRef<HTMLDivElement | null>(null);
-
- const [getResizeEvent, triggerResizeEvent] = useEventEmiiter();
-
- React.useEffect(() => {
- const { current: timelineElement } = timelineRef;
- if (timelineElement != null) {
- let loadingScrollToBottom = true;
- let pinBottom = false;
-
- const isAtBottom = (): boolean =>
- window.innerHeight + window.scrollY + 10 >= document.body.scrollHeight;
-
- const disableLoadingScrollToBottom = (): void => {
- loadingScrollToBottom = false;
- if (isAtBottom()) pinBottom = true;
- };
-
- const checkAndScrollToBottom = (): void => {
- if (loadingScrollToBottom || pinBottom) {
- window.scrollTo(0, document.body.scrollHeight);
- }
- };
-
- const subscriptions = [
- fromEvent(timelineElement, 'wheel').subscribe(
- disableLoadingScrollToBottom
- ),
- fromEvent(timelineElement, 'pointerdown').subscribe(
- disableLoadingScrollToBottom
- ),
- fromEvent(timelineElement, 'keydown').subscribe(
- disableLoadingScrollToBottom
- ),
- fromEvent(window, 'scroll').subscribe(() => {
- if (loadingScrollToBottom) return;
-
- if (isAtBottom()) {
- pinBottom = true;
- } else {
- pinBottom = false;
- }
- }),
- fromEvent(window, 'resize').subscribe(checkAndScrollToBottom),
- getResizeEvent().subscribe(checkAndScrollToBottom),
- ];
-
- return () => {
- subscriptions.forEach((s) => s.unsubscribe());
- };
- }
- }, [getResizeEvent, triggerResizeEvent, timeline, props.posts]);
-
- const [cardHeight, setCardHeight] = React.useState<number>(0);
-
- const genCardCollapseLocalStorageKey = (uniqueId: string): string =>
- `timeline.${uniqueId}.cardCollapse`;
-
- const cardCollapseLocalStorageKey =
- timeline != null ? genCardCollapseLocalStorageKey(timeline.uniqueId) : null;
-
- const [infoCardCollapse, setInfoCardCollapse] = React.useState<boolean>(true);
- React.useEffect(() => {
- if (cardCollapseLocalStorageKey != null) {
- const savedCollapse =
- window.localStorage.getItem(cardCollapseLocalStorageKey) === 'true';
- setInfoCardCollapse(savedCollapse);
- }
- }, [cardCollapseLocalStorageKey]);
-
- let body: React.ReactElement;
-
- if (props.error != null) {
- body = <p className="text-danger">{t(props.error)}</p>;
- } else {
- if (timeline != null) {
- let timelineBody: React.ReactElement;
- if (props.posts != null) {
- if (props.posts === 'forbid') {
- timelineBody = (
- <p className="text-danger">{t('timeline.messageCantSee')}</p>
- );
- } else {
- timelineBody = (
- <Timeline
- containerRef={timelineRef}
- posts={props.posts}
- onDelete={props.onDelete}
- onResize={triggerResizeEvent}
- />
- );
- if (props.onPost != null) {
- timelineBody = (
- <>
- {timelineBody}
- <div ref={bottomSpaceRef} className="flex-fix-length" />
- <TimelinePostEdit
- onPost={props.onPost}
- onHeightChange={onPostEditHeightChange}
- timelineUniqueId={timeline.uniqueId}
- />
- </>
- );
- }
- }
- } else {
- timelineBody = (
- <div className="full-viewport-center-child">
- <Spinner color="primary" type="grow" />
- </div>
- );
- }
- const { CardComponent } = props;
-
- body = (
- <>
- <div
- className="fixed-top mt-appbar info-card-container"
- data-collapse={infoCardCollapse ? 'true' : 'false'}
- >
- <Svg
- src={
- infoCardCollapse
- ? arrowsAngleExpandIcon
- : arrowsAngleContractIcon
- }
- onClick={() => {
- const newState = !infoCardCollapse;
- setInfoCardCollapse(newState);
- window.localStorage.setItem(
- genCardCollapseLocalStorageKey(timeline.uniqueId),
- newState.toString()
- );
- }}
- className="float-right m-1 info-card-collapse-button text-primary icon-button"
- />
- <CardComponent
- timeline={timeline}
- onManage={props.onManage}
- onMember={props.onMember}
- onHeight={setCardHeight}
- className="info-card-content"
- />
- </div>
- {timelineBody}
- </>
- );
- } else {
- body = (
- <div className="full-viewport-center-child">
- <Spinner color="primary" type="grow" />
- </div>
- );
- }
- }
-
- return (
- <>
- <AppBar />
- <div>
- <div
- style={{ height: 56 + cardHeight }}
- className="timeline-page-top-space flex-fix-length"
- />
- {body}
- </div>
- </>
- );
-}
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx
deleted file mode 100644
index d7e9d81b..00000000
--- a/Timeline/ClientApp/src/app/timeline/TimelinePageUI.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import React from 'react';
-
-import { ExcludeKey } from '../type-utilities';
-import { TimelineInfo } from '../data/timeline';
-
-import TimelinePageTemplateUI, {
- TimelinePageTemplateUIProps
-} from './TimelinePageTemplateUI';
-import TimelineInfoCard, {
- OrdinaryTimelineManageItem
-} from './TimelineInfoCard';
-
-export type TimelinePageUIProps = ExcludeKey<
- TimelinePageTemplateUIProps<TimelineInfo, OrdinaryTimelineManageItem>,
- 'CardComponent'
->;
-
-const TimelinePageUI: React.FC<TimelinePageUIProps> = props => {
- return <TimelinePageTemplateUI {...props} CardComponent={TimelineInfoCard} />;
-};
-
-export default TimelinePageUI;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx
deleted file mode 100644
index 894d6ad4..00000000
--- a/Timeline/ClientApp/src/app/timeline/TimelinePostEdit.tsx
+++ /dev/null
@@ -1,234 +0,0 @@
-import React from 'react';
-import { Button, Spinner, Row, Col } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import Svg from 'react-inlinesvg';
-
-import textIcon from 'bootstrap-icons/icons/card-text.svg';
-import imageIcon from 'bootstrap-icons/icons/image.svg';
-
-import { pushAlert } from '../common/alert-service';
-import { CreatePostRequest } from '../data/timeline';
-
-import FileInput from '../common/FileInput';
-import { UiLogicError } from '../common';
-
-interface TimelinePostEditImageProps {
- onSelect: (blob: Blob | null) => void;
-}
-
-const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
- const { onSelect } = props;
- const { t } = useTranslation();
-
- const [file, setFile] = React.useState<File | null>(null);
- const [fileUrl, setFileUrl] = React.useState<string | null>(null);
- const [error, setError] = React.useState<string | null>(null);
-
- React.useEffect(() => {
- if (file != null) {
- const url = URL.createObjectURL(file);
- setFileUrl(url);
- return () => {
- URL.revokeObjectURL(url);
- };
- }
- }, [file]);
-
- const onInputChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback(
- (e) => {
- const files = e.target.files;
- if (files == null || files.length === 0) {
- setFile(null);
- setFileUrl(null);
- } else {
- setFile(files[0]);
- }
- onSelect(null);
- setError(null);
- },
- [onSelect]
- );
-
- const onImgLoad = React.useCallback(() => {
- onSelect(file);
- }, [onSelect, file]);
-
- const onImgError = React.useCallback(() => {
- setError('loadImageError');
- }, []);
-
- return (
- <>
- <FileInput
- labelText={t('chooseImage')}
- onChange={onInputChange}
- accept="image/*"
- className="mx-3 my-1"
- />
- {fileUrl && error == null && (
- <img
- src={fileUrl}
- className="timeline-post-edit-image"
- onLoad={onImgLoad}
- onError={onImgError}
- />
- )}
- {error != null && <div className="text-danger">{t(error)}</div>}
- </>
- );
-};
-
-export type TimelinePostSendCallback = (
- content: CreatePostRequest
-) => Promise<void>;
-
-export interface TimelinePostEditProps {
- className?: string;
- onPost: TimelinePostSendCallback;
- onHeightChange?: (height: number) => void;
- timelineUniqueId: string;
-}
-
-const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
- const { onPost } = props;
-
- const { t } = useTranslation();
-
- const [state, setState] = React.useState<'input' | 'process'>('input');
- const [kind, setKind] = React.useState<'text' | 'image'>('text');
- const [text, setText] = React.useState<string>('');
- const [imageBlob, setImageBlob] = React.useState<Blob | null>(null);
-
- const draftLocalStorageKey = `timeline.${props.timelineUniqueId}.postDraft`;
-
- React.useEffect(() => {
- setText(window.localStorage.getItem(draftLocalStorageKey) ?? '');
- }, [draftLocalStorageKey]);
-
- const canSend = kind === 'text' || (kind === 'image' && imageBlob != null);
-
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const containerRef = React.useRef<HTMLDivElement>(null!);
-
- const notifyHeightChange = (): void => {
- if (props.onHeightChange) {
- props.onHeightChange(containerRef.current.clientHeight);
- }
- };
-
- React.useEffect(() => {
- if (props.onHeightChange) {
- props.onHeightChange(containerRef.current.clientHeight);
- }
- return () => {
- if (props.onHeightChange) {
- props.onHeightChange(0);
- }
- };
- });
-
- const toggleKind = React.useCallback(() => {
- setKind((oldKind) => (oldKind === 'text' ? 'image' : 'text'));
- setImageBlob(null);
- }, []);
-
- const onSend = React.useCallback(() => {
- setState('process');
-
- const req: CreatePostRequest = (() => {
- switch (kind) {
- case 'text':
- return {
- content: {
- type: 'text',
- text: text,
- },
- } as CreatePostRequest;
- case 'image':
- if (imageBlob == null) {
- throw new UiLogicError(
- 'Content type is image but image blob is null.'
- );
- }
- return {
- content: {
- type: 'image',
- data: imageBlob,
- },
- } as CreatePostRequest;
- default:
- throw new UiLogicError('Unknown content type.');
- }
- })();
-
- onPost(req).then(
- (_) => {
- if (kind === 'text') {
- setText('');
- window.localStorage.removeItem(draftLocalStorageKey);
- }
- setState('input');
- setKind('text');
- },
- (_) => {
- pushAlert({
- type: 'danger',
- message: t('timeline.sendPostFailed'),
- });
- setState('input');
- }
- );
- }, [onPost, kind, text, imageBlob, t, draftLocalStorageKey]);
-
- const onImageSelect = React.useCallback((blob: Blob | null) => {
- setImageBlob(blob);
- }, []);
-
- return (
- <div ref={containerRef} className="container-fluid fixed-bottom bg-light">
- <Row>
- <Col className="px-1 py-1">
- {kind === 'text' ? (
- <textarea
- className="w-100 h-100 timeline-post-edit"
- value={text}
- disabled={state === 'process'}
- onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
- const value = event.currentTarget.value;
- setText(value);
- window.localStorage.setItem(draftLocalStorageKey, value);
- }}
- />
- ) : (
- <TimelinePostEditImage onSelect={onImageSelect} />
- )}
- </Col>
- <Col sm="col-auto align-self-end m-1">
- {(() => {
- if (state === 'input') {
- return (
- <>
- <div className="d-block text-center mt-1 mb-2">
- <Svg
- onLoad={notifyHeightChange}
- src={kind === 'text' ? imageIcon : textIcon}
- className="icon-button"
- onClick={toggleKind}
- />
- </div>
- <Button color="primary" onClick={onSend} disabled={!canSend}>
- {t('timeline.send')}
- </Button>
- </>
- );
- } else {
- return <Spinner />;
- }
- })()}
- </Col>
- </Row>
- </div>
- );
-};
-
-export default TimelinePostEdit;
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx
deleted file mode 100644
index ca1be31c..00000000
--- a/Timeline/ClientApp/src/app/timeline/TimelinePropertyChangeDialog.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import React from 'react';
-
-import {
- TimelineVisibility,
- kTimelineVisibilities,
- PersonalTimelineChangePropertyRequest
-} from '../data/timeline';
-
-import OperationDialog, {
- OperationSelectInputInfoOption
-} from '../common/OperationDialog';
-
-export interface TimelinePropertyInfo {
- visibility: TimelineVisibility;
- description: string;
-}
-
-export interface TimelinePropertyChangeDialogProps {
- open: boolean;
- close: () => void;
- oldInfo: TimelinePropertyInfo;
- onProcess: (request: PersonalTimelineChangePropertyRequest) => Promise<void>;
-}
-
-const labelMap: { [key in TimelineVisibility]: string } = {
- Private: 'timeline.visibility.private',
- Public: 'timeline.visibility.public',
- Register: 'timeline.visibility.register'
-};
-
-const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> = props => {
- return (
- <OperationDialog
- title={'timeline.dialogChangeProperty.title'}
- titleColor="default"
- inputScheme={[
- {
- type: 'select',
- label: 'timeline.dialogChangeProperty.visibility',
- options: kTimelineVisibilities.map<OperationSelectInputInfoOption>(
- v => ({
- label: labelMap[v],
- value: v
- })
- ),
- initValue: props.oldInfo.visibility
- },
- {
- type: 'text',
- label: 'timeline.dialogChangeProperty.description',
- initValue: props.oldInfo.description
- }
- ]}
- open={props.open}
- close={props.close}
- onProcess={([newVisibility, newDescription]) => {
- const req: PersonalTimelineChangePropertyRequest = {};
- if (newVisibility !== props.oldInfo.visibility) {
- req.visibility = newVisibility as TimelineVisibility;
- }
- if (newDescription !== props.oldInfo.description) {
- req.description = newDescription as string;
- }
- return props.onProcess(req);
- }}
- />
- );
-};
-
-export default TimelinePropertyChangeDialog;
diff --git a/Timeline/ClientApp/src/app/timeline/timeline-ui.sass b/Timeline/ClientApp/src/app/timeline/timeline-ui.sass
deleted file mode 100644
index c3616caf..00000000
--- a/Timeline/ClientApp/src/app/timeline/timeline-ui.sass
+++ /dev/null
@@ -1,18 +0,0 @@
-.info-card-container
- .info-card-collapse-button
- z-index: 1
- position: relative
-
- .info-card-content
- width: 100%
- position: absolute
- transform-origin: right top
- transition: transform 0.5s
-
- &[data-collapse='true']
- .info-card-content
- transform: scale(0)
-
-.timeline-page-top-space
- transition: height 0.5s
-
diff --git a/Timeline/ClientApp/src/app/timeline/timeline.sass b/Timeline/ClientApp/src/app/timeline/timeline.sass
deleted file mode 100644
index 0b0e73b5..00000000
--- a/Timeline/ClientApp/src/app/timeline/timeline.sass
+++ /dev/null
@@ -1,125 +0,0 @@
-@use 'sass:color'
-
-@keyframes timeline-enter-animation-mask-animation
- to
- height: 0
-
-.timeline-enter-animation-mask
- position: absolute
- left: 0
- top: 0
- height: calc(100% + 300px)
- width: 100%
- background: linear-gradient(to top, #ffffff00 0, 200px, white 300px, white)
- z-index: 100
- animation: timeline-enter-animation-mask-animation 5s 0.3s forwards // Give it 0.3s to load, which I think is reasonable
-
-$timeline-line-width: 7px
-$timeline-line-node-radius: 18px
-$timeline-line-color: $primary
-$timeline-line-color-current: #36c2e6
-
-@keyframes timeline-line-node-noncurrent
- from
- background: $timeline-line-color
-
- to
- background: color.adjust($timeline-line-color, $lightness: +10%)
- box-shadow: 0 0 20px 3px color.adjust($timeline-line-color, $lightness: +10%, $alpha: -0.1)
-
-
-@keyframes timeline-line-node-current
- from
- background: $timeline-line-color-current
-
- to
- background: color.adjust($timeline-line-color-current, $lightness: +10%)
- box-shadow: 0 0 20px 3px color.adjust($timeline-line-color-current, $lightness: +10%, $alpha: -0.1)
-
-.timeline-line
- &-area
- display: flex
- flex-direction: column
- align-items: center
- flex: 0 0 auto
- width: 60px
-
- &-segment
- width: $timeline-line-width
- background: $timeline-line-color
-
- &.start
- height: 20px
- flex: 0 0 auto
-
- &.end
- flex: 1 1 auto
-
- &.current-end
- height: 20px
- flex: 0 0 auto
- background: linear-gradient($timeline-line-color-current, transparent)
-
- &-node-container
- flex: 0 0 auto
- position: relative
- width: $timeline-line-node-radius
- height: $timeline-line-node-radius
-
- &-node
- width: $timeline-line-node-radius + 2
- height: $timeline-line-node-radius + 2
- position: absolute
- left: -1px
- top: -1px
- border-radius: 50%
- box-sizing: border-box
- z-index: 1
- animation: 1s infinite alternate
- animation-name: timeline-line-node-noncurrent
-
-
-.current
- .timeline-line
- &-segment
-
- &.start
- background: linear-gradient($timeline-line-color, $timeline-line-color-current)
-
- &.end
- background: $timeline-line-color-current
-
- &-node
- animation-name: timeline-line-node-current
-
-.timeline-pt-start
- padding-top: 18px
-
-.timeline-item-delete-button
- position: absolute
- right: 0
- bottom: 0
-
-.timeline-content
- white-space: pre-line
-
-.timeline-content-image
- max-width: 60%
- max-height: 200px
-
-
-.timeline-post-edit-image
- max-width: 100px
- max-height: 100px
-
-.mask
- background: change-color($color: white, $alpha: 0.8)
- z-index: 100
-
-textarea.timeline-post-edit
- @extend .border-primary
- @extend .rounded
-
- &:focus
- outline: none
- box-shadow: 0 0 5px 0 $primary