aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src')
-rw-r--r--FrontEnd/src/app/common.ts47
-rw-r--r--FrontEnd/src/app/services/alert.ts4
-rw-r--r--FrontEnd/src/app/utilities/mediaQuery.ts5
-rw-r--r--FrontEnd/src/app/views/common/FullPage.tsx31
-rw-r--r--FrontEnd/src/app/views/common/Menu.tsx83
-rw-r--r--FrontEnd/src/app/views/common/common.sass42
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageCardTemplate.tsx180
-rw-r--r--FrontEnd/src/app/views/timeline-common/timeline-common.sass2
-rw-r--r--FrontEnd/src/app/views/timeline/TimelineCard.tsx27
-rw-r--r--FrontEnd/src/app/views/user/UserCard.tsx26
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}