diff options
author | crupest <crupest@outlook.com> | 2021-02-20 22:45:07 +0800 |
---|---|---|
committer | crupest <crupest@outlook.com> | 2021-02-20 22:45:07 +0800 |
commit | ad61f58644b3f0fac451dbc5b869471edcad0cf4 (patch) | |
tree | 8dd1cb3e15a3a8f4d90c0b57a028de08adfa2244 | |
parent | a58ea3757f4749406274142b47e4d6d90ecf4015 (diff) | |
download | timeline-ad61f58644b3f0fac451dbc5b869471edcad0cf4.tar.gz timeline-ad61f58644b3f0fac451dbc5b869471edcad0cf4.tar.bz2 timeline-ad61f58644b3f0fac451dbc5b869471edcad0cf4.zip |
...
-rw-r--r-- | FrontEnd/src/app/common.ts | 47 | ||||
-rw-r--r-- | FrontEnd/src/app/services/alert.ts | 4 | ||||
-rw-r--r-- | FrontEnd/src/app/utilities/mediaQuery.ts | 5 | ||||
-rw-r--r-- | FrontEnd/src/app/views/common/FullPage.tsx | 31 | ||||
-rw-r--r-- | FrontEnd/src/app/views/common/Menu.tsx | 83 | ||||
-rw-r--r-- | FrontEnd/src/app/views/common/common.sass | 42 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline-common/TimelinePageCardTemplate.tsx | 180 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline-common/timeline-common.sass | 2 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline/TimelineCard.tsx | 27 | ||||
-rw-r--r-- | FrontEnd/src/app/views/user/UserCard.tsx | 26 |
10 files changed, 267 insertions, 180 deletions
diff --git a/FrontEnd/src/app/common.ts b/FrontEnd/src/app/common.ts index 681568bb..1a4f6dda 100644 --- a/FrontEnd/src/app/common.ts +++ b/FrontEnd/src/app/common.ts @@ -1,49 +1,18 @@ -import React from "react"; -import { Observable, Subject } from "rxjs"; import { TFunction } from "i18next"; +export type BootstrapThemeColor = + | "primary" + | "secondary" + | "success" + | "danger" + | "warning" + | "info"; + // This error is thrown when ui goes wrong with bad logic. // Such as a variable should not be null, but it does. // This error should never occur. If it does, it indicates there is some logic bug in codes. export class UiLogicError extends Error {} -export function useEventEmiiter(): [() => Observable<null>, () => void] { - const ref = React.useRef<Subject<null> | null>(null); - - return React.useMemo(() => { - const getter = (): Subject<null> => { - if (ref.current == null) { - ref.current = new Subject<null>(); - } - return ref.current; - }; - const trigger = (): void => { - getter().next(null); - }; - return [getter, trigger]; - }, []); -} - -export function useValueEventEmiiter<T>(): [ - () => Observable<T>, - (value: T) => void -] { - const ref = React.useRef<Subject<T> | null>(null); - - return React.useMemo(() => { - const getter = (): Subject<T> => { - if (ref.current == null) { - ref.current = new Subject<T>(); - } - return ref.current; - }; - const trigger = (value: T): void => { - getter().next(value); - }; - return [getter, trigger]; - }, []); -} - export type I18nText = | string | { type: "custom"; value: string } diff --git a/FrontEnd/src/app/services/alert.ts b/FrontEnd/src/app/services/alert.ts index b6d6be6e..48d482ea 100644 --- a/FrontEnd/src/app/services/alert.ts +++ b/FrontEnd/src/app/services/alert.ts @@ -1,10 +1,10 @@ import React from "react"; import pull from "lodash/pull"; -import { I18nText } from "@/common"; +import { BootstrapThemeColor, I18nText } from "@/common"; export interface AlertInfo { - type?: "primary" | "secondary" | "success" | "danger" | "warning" | "info"; + type?: BootstrapThemeColor; message: React.FC<unknown> | I18nText; dismissTime?: number | "never"; } diff --git a/FrontEnd/src/app/utilities/mediaQuery.ts b/FrontEnd/src/app/utilities/mediaQuery.ts new file mode 100644 index 00000000..ad55c3c0 --- /dev/null +++ b/FrontEnd/src/app/utilities/mediaQuery.ts @@ -0,0 +1,5 @@ +import { useMediaQuery } from "react-responsive"; + +export function useIsSmallScreen(): boolean { + return useMediaQuery({ maxWidth: 576 }); +} diff --git a/FrontEnd/src/app/views/common/FullPage.tsx b/FrontEnd/src/app/views/common/FullPage.tsx new file mode 100644 index 00000000..09b01647 --- /dev/null +++ b/FrontEnd/src/app/views/common/FullPage.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +export interface FullPageProps { + show: boolean; + onBack: () => void; + contentContainerClassName?: string; +} + +const FullPage: React.FC<FullPageProps> = ({ + show, + onBack, + children, + contentContainerClassName, +}) => { + return ( + <div + className="cru-full-page" + style={{ display: show ? undefined : "none" }} + > + <div className="cru-full-page-top-bar"> + <i + className="icon-button bi-arrow-left text-white ml-3" + onClick={onBack} + /> + </div> + <div className={contentContainerClassName}>{children}</div> + </div> + ); +}; + +export default FullPage; diff --git a/FrontEnd/src/app/views/common/Menu.tsx b/FrontEnd/src/app/views/common/Menu.tsx new file mode 100644 index 00000000..c2110c9c --- /dev/null +++ b/FrontEnd/src/app/views/common/Menu.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import clsx from "clsx"; +import { OverlayTrigger, OverlayTriggerProps, Popover } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +import { BootstrapThemeColor, convertI18nText, I18nText } from "@/common"; + +export type MenuItem = + | { + type: "divider"; + } + | { + type: "button"; + text: I18nText; + color?: BootstrapThemeColor; + onClick: () => void; + }; + +export type MenuItems = MenuItem[]; + +export interface MenuProps { + items: MenuItems; + className?: string; + onItemClicked?: () => void; +} + +const Menu: React.FC<MenuProps> = ({ items, className, onItemClicked }) => { + const { t } = useTranslation(); + + return ( + <div className={clsx("cru-menu", className)}> + {items.map((item) => { + if (item.type === "divider") { + return <div className="cru-menu-divider" />; + } else { + return ( + <div + className={clsx( + "cru-menu-item", + `color-${item.color ?? "primary"}` + )} + onClick={() => { + item.onClick(); + onItemClicked?.(); + }} + > + {convertI18nText(item.text, t)} + </div> + ); + } + })} + </div> + ); +}; + +export default Menu; + +export interface PopupMenuProps { + items: MenuItems; + children: OverlayTriggerProps["children"]; +} + +export const PopupMenu: React.FC<PopupMenuProps> = ({ items, children }) => { + const [show, setShow] = React.useState<boolean>(false); + const toggle = (): void => setShow(!show); + + return ( + <OverlayTrigger + trigger="click" + placement="bottom" + rootClose + overlay={ + <Popover id="menu-popover"> + <Menu items={items} onItemClicked={() => setShow(false)} /> + </Popover> + } + show={show} + onToggle={toggle} + > + {children} + </OverlayTrigger> + ); +}; diff --git a/FrontEnd/src/app/views/common/common.sass b/FrontEnd/src/app/views/common/common.sass index e2ee8978..329ea683 100644 --- a/FrontEnd/src/app/views/common/common.sass +++ b/FrontEnd/src/app/views/common/common.sass @@ -43,3 +43,45 @@ &.last
width: 50%
+
+.cru-full-page
+ position: fixed
+ z-index: 1031
+ left: 0
+ top: 0
+ right: 0
+ bottom: 0
+ background-color: white
+ padding-top: 56px
+
+.cru-full-page-top-bar
+ height: 56px
+
+ position: absolute
+ top: 0
+ left: 0
+ right: 0
+ z-index: 1
+
+ @extend .bg-primary
+
+ display: flex
+ align-items: center
+
+.cru-menu
+ min-width: 200px
+
+.cru-menu-item
+ font-size: 1.2em
+ padding: 0.5em 1.5em
+ cursor: pointer
+
+ @each $color, $value in $theme-colors
+ &.color-#{$color}
+ color: $value
+
+ &:hover
+ color: white
+ background-color: $value
+
+.cru-menu-divider
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageCardTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageCardTemplate.tsx index 13b823bf..6069d54e 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageCardTemplate.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageCardTemplate.tsx @@ -1,7 +1,6 @@ import React from "react"; import clsx from "clsx"; import { useTranslation } from "react-i18next"; -import { Dropdown, Button } from "react-bootstrap"; import { getHttpHighlightClient } from "@/http/highlight"; import { getHttpBookmarkClient } from "@/http/bookmark"; @@ -10,28 +9,19 @@ import { useUser } from "@/services/user"; import { pushAlert } from "@/services/alert"; import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline"; +import { useIsSmallScreen } from "@/utilities/mediaQuery"; + import { TimelinePageCardProps } from "./TimelinePageTemplate"; import CollapseButton from "./CollapseButton"; import { TimelineMemberDialog } from "./TimelineMember"; import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; +import { MenuItems, PopupMenu } from "../common/Menu"; +import FullPage from "../common/FullPage"; export interface TimelineCardTemplateProps extends TimelinePageCardProps { infoArea: React.ReactElement; - manageArea: - | { type: "member" } - | { - type: "manage"; - items: ( - | { - type: "button"; - text: string; - color?: string; - onClick: () => void; - } - | { type: "divider" } - )[]; - }; + manageItems?: MenuItems; dialog: string | "property" | "member" | null; setDialog: (dialog: "property" | "member" | null) => void; } @@ -41,7 +31,7 @@ const TimelinePageCardTemplate: React.FC<TimelineCardTemplateProps> = ({ collapse, toggleCollapse, infoArea, - manageArea, + manageItems, onReload, className, dialog, @@ -49,100 +39,90 @@ const TimelinePageCardTemplate: React.FC<TimelineCardTemplateProps> = ({ }) => { const { t } = useTranslation(); + const isSmallScreen = useIsSmallScreen(); + const user = useUser(); - return ( + const content = ( <> - <div className={clsx("cru-card p-2 clearfix", className)}> - <div className="float-right d-flex align-items-center"> - <CollapseButton collapse={collapse} onClick={toggleCollapse} /> - </div> - <div style={{ display: collapse ? "none" : "block" }}> - {infoArea} - <p className="mb-0">{timeline.description}</p> - <small className="mt-1 d-block"> - {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} - </small> - <div className="text-right mt-2"> - <i - className={clsx( - timeline.isHighlight ? "bi-star-fill" : "bi-star", - "icon-button text-yellow mr-3" - )} - onClick={ - user?.hasHighlightTimelineAdministrationPermission - ? () => { - getHttpHighlightClient() - [timeline.isHighlight ? "delete" : "put"](timeline.name) - .then(onReload, () => { - pushAlert({ - message: timeline.isHighlight - ? "timeline.removeHighlightFail" - : "timeline.addHighlightFail", - type: "danger", - }); - }); - } - : undefined - } - /> - {user != null ? ( - <i - className={clsx( - timeline.isBookmark ? "bi-bookmark-fill" : "bi-bookmark", - "icon-button text-yellow mr-3" - )} - onClick={() => { - getHttpBookmarkClient() - [timeline.isBookmark ? "delete" : "put"](timeline.name) + {infoArea} + <p className="mb-0">{timeline.description}</p> + <small className="mt-1 d-block"> + {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} + </small> + <div className="text-right mt-2"> + <i + className={clsx( + timeline.isHighlight ? "bi-star-fill" : "bi-star", + "icon-button text-yellow mr-3" + )} + onClick={ + user?.hasHighlightTimelineAdministrationPermission + ? () => { + getHttpHighlightClient() + [timeline.isHighlight ? "delete" : "put"](timeline.name) .then(onReload, () => { pushAlert({ - message: timeline.isBookmark - ? "timeline.removeBookmarkFail" - : "timeline.addBookmarkFail", + message: timeline.isHighlight + ? "timeline.removeHighlightFail" + : "timeline.addHighlightFail", type: "danger", }); }); - }} - /> - ) : null} - {manageArea.type === "manage" ? ( - <Dropdown className="d-inline-block"> - <Dropdown.Toggle variant="outline-primary"> - {t("timeline.manage")} - </Dropdown.Toggle> - <Dropdown.Menu> - {manageArea.items.map((item, index) => { - if (item.type === "divider") { - return <Dropdown.Divider key={index} />; - } else { - return ( - <Dropdown.Item - key={index} - onClick={item.onClick} - className={ - item.color != null - ? "text-" + item.color - : undefined - } - > - {t(item.text)} - </Dropdown.Item> - ); - } - })} - </Dropdown.Menu> - </Dropdown> - ) : ( - <Button - variant="outline-primary" - onClick={() => setDialog("member")} - > - {t("timeline.memberButton")} - </Button> + } + : undefined + } + /> + {user != null ? ( + <i + className={clsx( + timeline.isBookmark ? "bi-bookmark-fill" : "bi-bookmark", + "icon-button text-yellow mr-3" )} - </div> + onClick={() => { + getHttpBookmarkClient() + [timeline.isBookmark ? "delete" : "put"](timeline.name) + .then(onReload, () => { + pushAlert({ + message: timeline.isBookmark + ? "timeline.removeBookmarkFail" + : "timeline.addBookmarkFail", + type: "danger", + }); + }); + }} + /> + ) : null} + <i + className={"icon-button bi-people text-primary mr-3"} + onClick={() => setDialog("member")} + /> + {manageItems != null ? ( + <PopupMenu items={manageItems}> + <i className="icon-button bi-three-dots-vertical text-primary" /> + </PopupMenu> + ) : null} + </div> + </> + ); + + return ( + <> + <div className={clsx("cru-card p-2 clearfix", className)}> + <div className="float-right d-flex align-items-center"> + <CollapseButton collapse={collapse} onClick={toggleCollapse} /> </div> + {isSmallScreen ? ( + <FullPage + onBack={toggleCollapse} + show={!collapse} + contentContainerClassName="p-2" + > + {content} + </FullPage> + ) : ( + <div style={{ display: collapse ? "none" : "block" }}>{content}</div> + )} </div> {(() => { if (dialog === "member") { diff --git a/FrontEnd/src/app/views/timeline-common/timeline-common.sass b/FrontEnd/src/app/views/timeline-common/timeline-common.sass index 31404d8c..c74058c0 100644 --- a/FrontEnd/src/app/views/timeline-common/timeline-common.sass +++ b/FrontEnd/src/app/views/timeline-common/timeline-common.sass @@ -166,7 +166,7 @@ $timeline-line-color-current: #36c2e6 .timeline-template-card position: fixed - z-index: 1 + z-index: 1031 top: 56px right: 0 margin: 0.5em diff --git a/FrontEnd/src/app/views/timeline/TimelineCard.tsx b/FrontEnd/src/app/views/timeline/TimelineCard.tsx index a777cbbd..2a9bcfc8 100644 --- a/FrontEnd/src/app/views/timeline/TimelineCard.tsx +++ b/FrontEnd/src/app/views/timeline/TimelineCard.tsx @@ -1,9 +1,8 @@ import React from "react"; -import TimelinePageCardTemplate, { - TimelineCardTemplateProps, -} from "../timeline-common/TimelinePageCardTemplate"; import { TimelinePageCardProps } from "../timeline-common/TimelinePageTemplate"; +import TimelinePageCardTemplate from "../timeline-common/TimelinePageCardTemplate"; + import UserAvatar from "../common/user/UserAvatar"; import TimelineDeleteDialog from "./TimelineDeleteDialog"; @@ -35,23 +34,14 @@ const TimelineCard: React.FC<TimelinePageCardProps> = (props) => { </div> </> } - manageArea={((): TimelineCardTemplateProps["manageArea"] => { - if (!timeline.manageable) { - return { type: "member" }; - } else { - return { - type: "manage", - items: [ + manageItems={ + timeline.manageable + ? [ { type: "button", text: "timeline.manageItem.property", onClick: () => setDialog("property"), }, - { - type: "button", - onClick: () => setDialog("member"), - text: "timeline.manageItem.member", - }, { type: "divider" }, { type: "button", @@ -59,10 +49,9 @@ const TimelineCard: React.FC<TimelinePageCardProps> = (props) => { color: "danger", text: "timeline.manageItem.delete", }, - ], - }; - } - })()} + ] + : undefined + } dialog={dialog} setDialog={setDialog} {...props} diff --git a/FrontEnd/src/app/views/user/UserCard.tsx b/FrontEnd/src/app/views/user/UserCard.tsx index b2c94457..552f5ced 100644 --- a/FrontEnd/src/app/views/user/UserCard.tsx +++ b/FrontEnd/src/app/views/user/UserCard.tsx @@ -1,8 +1,6 @@ import React from "react"; -import TimelinePageCardTemplate, { - TimelineCardTemplateProps, -} from "../timeline-common/TimelinePageCardTemplate"; +import TimelinePageCardTemplate from "../timeline-common/TimelinePageCardTemplate"; import { TimelinePageCardProps } from "../timeline-common/TimelinePageTemplate"; import UserAvatar from "../common/user/UserAvatar"; @@ -31,27 +29,17 @@ const UserCard: React.FC<TimelinePageCardProps> = (props) => { </div> </> } - manageArea={((): TimelineCardTemplateProps["manageArea"] => { - if (!timeline.manageable) { - return { type: "member" }; - } else { - return { - type: "manage", - items: [ + manageItems={ + timeline.manageable + ? [ { type: "button", text: "timeline.manageItem.property", onClick: () => setDialog("property"), }, - { - type: "button", - text: "timeline.manageItem.member", - onClick: () => setDialog("member"), - }, - ], - }; - } - })()} + ] + : undefined + } dialog={dialog} setDialog={setDialog} {...props} |