aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/views/center
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src/views/center')
-rw-r--r--FrontEnd/src/views/center/CenterBoards.tsx107
-rw-r--r--FrontEnd/src/views/center/TimelineBoard.tsx370
-rw-r--r--FrontEnd/src/views/center/TimelineCreateDialog.tsx53
-rw-r--r--FrontEnd/src/views/center/center.sass36
-rw-r--r--FrontEnd/src/views/center/index.tsx64
5 files changed, 630 insertions, 0 deletions
diff --git a/FrontEnd/src/views/center/CenterBoards.tsx b/FrontEnd/src/views/center/CenterBoards.tsx
new file mode 100644
index 00000000..431d1e9a
--- /dev/null
+++ b/FrontEnd/src/views/center/CenterBoards.tsx
@@ -0,0 +1,107 @@
+import React from "react";
+import { Row, Col } from "react-bootstrap";
+import { useTranslation } from "react-i18next";
+
+import { pushAlert } from "@/services/alert";
+import { useUserLoggedIn } from "@/services/user";
+
+import { getHttpTimelineClient } from "http/timeline";
+import { getHttpBookmarkClient } from "http/bookmark";
+import { getHttpHighlightClient } from "http/highlight";
+
+import TimelineBoard from "./TimelineBoard";
+
+const CenterBoards: React.FC = () => {
+ const { t } = useTranslation();
+
+ const user = useUserLoggedIn();
+
+ return (
+ <>
+ <Row className="justify-content-center">
+ <Col xs="12" md="6">
+ <Row>
+ <Col xs="12" className="my-2">
+ <TimelineBoard
+ title={t("home.bookmarkTimeline")}
+ load={() => getHttpBookmarkClient().list()}
+ editHandler={{
+ onDelete: (timeline) => {
+ return getHttpBookmarkClient()
+ .delete(timeline)
+ .catch((e) => {
+ pushAlert({
+ message: "home.message.deleteBookmarkFail",
+ type: "danger",
+ });
+ throw e;
+ });
+ },
+ onMove: (timeline, index, offset) => {
+ return getHttpBookmarkClient()
+ .move(
+ { timeline, newPosition: index + offset + 1 } // +1 because backend contract: index starts at 1
+ )
+ .catch((e) => {
+ pushAlert({
+ message: "home.message.moveBookmarkFail",
+ type: "danger",
+ });
+ throw e;
+ });
+ },
+ }}
+ />
+ </Col>
+ <Col xs="12" className="my-2">
+ <TimelineBoard
+ title={t("home.highlightTimeline")}
+ load={() => getHttpHighlightClient().list()}
+ editHandler={
+ user.hasHighlightTimelineAdministrationPermission
+ ? {
+ onDelete: (timeline) => {
+ return getHttpHighlightClient()
+ .delete(timeline)
+ .catch((e) => {
+ pushAlert({
+ message: "home.message.deleteHighlightFail",
+ type: "danger",
+ });
+ throw e;
+ });
+ },
+ onMove: (timeline, index, offset) => {
+ return getHttpHighlightClient()
+ .move(
+ { timeline, newPosition: index + offset + 1 } // +1 because backend contract: index starts at 1
+ )
+ .catch((e) => {
+ pushAlert({
+ message: "home.message.moveHighlightFail",
+ type: "danger",
+ });
+ throw e;
+ });
+ },
+ }
+ : undefined
+ }
+ />
+ </Col>
+ </Row>
+ </Col>
+ <Col xs="12" md="6" className="my-2">
+ <TimelineBoard
+ title={t("home.relatedTimeline")}
+ load={() =>
+ getHttpTimelineClient().listTimeline({ relate: user.username })
+ }
+ />
+ </Col>
+ </Row>
+ </>
+ );
+};
+
+export default CenterBoards;
diff --git a/FrontEnd/src/views/center/TimelineBoard.tsx b/FrontEnd/src/views/center/TimelineBoard.tsx
new file mode 100644
index 00000000..bb80266b
--- /dev/null
+++ b/FrontEnd/src/views/center/TimelineBoard.tsx
@@ -0,0 +1,370 @@
+import React from "react";
+import classnames from "classnames";
+import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { Spinner } from "react-bootstrap";
+
+import { HttpTimelineInfo } from "http/timeline";
+
+import TimelineLogo from "../common/TimelineLogo";
+import UserTimelineLogo from "../common/UserTimelineLogo";
+import LoadFailReload from "../common/LoadFailReload";
+
+interface TimelineBoardItemProps {
+ timeline: HttpTimelineInfo;
+ // In height.
+ offset?: number;
+ // In px.
+ arbitraryOffset?: number;
+ // If not null, will disable navigation on click.
+ actions?: {
+ onDelete: () => void;
+ onMove: {
+ start: (e: React.PointerEvent) => void;
+ moving: (e: React.PointerEvent) => void;
+ end: (e: React.PointerEvent) => void;
+ };
+ };
+}
+
+const TimelineBoardItem: React.FC<TimelineBoardItemProps> = ({
+ timeline,
+ arbitraryOffset,
+ offset,
+ actions,
+}) => {
+ const { name, title } = timeline;
+ const isPersonal = name.startsWith("@");
+ const url = isPersonal
+ ? `/users/${timeline.owner.username}`
+ : `/timelines/${name}`;
+
+ const content = (
+ <>
+ {isPersonal ? (
+ <UserTimelineLogo className="icon" />
+ ) : (
+ <TimelineLogo className="icon" />
+ )}
+ <span className="title">{title}</span>
+ <small className="ms-2 text-secondary">{name}</small>
+ <span className="flex-grow-1"></span>
+ {actions != null ? (
+ <div className="right">
+ <i
+ className="bi-trash icon-button text-danger px-2"
+ onClick={actions.onDelete}
+ />
+ <i
+ className="bi-grip-vertical icon-button text-gray px-2 touch-action-none"
+ onPointerDown={(e) => {
+ e.currentTarget.setPointerCapture(e.pointerId);
+ actions.onMove.start(e);
+ }}
+ onPointerUp={(e) => {
+ actions.onMove.end(e);
+ try {
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ } catch (_) {
+ void null;
+ }
+ }}
+ onPointerMove={actions.onMove.moving}
+ />
+ </div>
+ ) : null}
+ </>
+ );
+
+ const offsetStyle: React.CSSProperties = {
+ transform:
+ arbitraryOffset != null
+ ? `translate(0,${arbitraryOffset}px)`
+ : offset != null
+ ? `translate(0,${offset * 100}%)`
+ : undefined,
+ transition: offset != null ? "transform 0.5s" : undefined,
+ zIndex: arbitraryOffset != null ? 1 : undefined,
+ };
+
+ return actions == null ? (
+ <Link to={url} className="timeline-board-item">
+ {content}
+ </Link>
+ ) : (
+ <div style={offsetStyle} className="timeline-board-item">
+ {content}
+ </div>
+ );
+};
+
+interface TimelineBoardItemContainerProps {
+ timelines: HttpTimelineInfo[];
+ editHandler?: {
+ // offset may exceed index range plusing index.
+ onMove: (timeline: string, index: number, offset: number) => void;
+ onDelete: (timeline: string) => void;
+ };
+}
+
+const TimelineBoardItemContainer: React.FC<TimelineBoardItemContainerProps> = ({
+ timelines,
+ editHandler,
+}) => {
+ const [moveState, setMoveState] = React.useState<null | {
+ index: number;
+ offset: number;
+ startPointY: number;
+ }>(null);
+
+ return (
+ <>
+ {timelines.map((timeline, index) => {
+ const height = 48;
+
+ let offset: number | undefined = undefined;
+ let arbitraryOffset: number | undefined = undefined;
+ if (moveState != null) {
+ if (index === moveState.index) {
+ arbitraryOffset = moveState.offset;
+ } else {
+ if (moveState.offset >= 0) {
+ const offsetCount = Math.round(moveState.offset / height);
+ if (
+ index > moveState.index &&
+ index <= moveState.index + offsetCount
+ ) {
+ offset = -1;
+ } else {
+ offset = 0;
+ }
+ } else {
+ const offsetCount = Math.round(-moveState.offset / height);
+ if (
+ index < moveState.index &&
+ index >= moveState.index - offsetCount
+ ) {
+ offset = 1;
+ } else {
+ offset = 0;
+ }
+ }
+ }
+ }
+
+ return (
+ <TimelineBoardItem
+ key={timeline.name}
+ timeline={timeline}
+ offset={offset}
+ arbitraryOffset={arbitraryOffset}
+ actions={
+ editHandler != null
+ ? {
+ onDelete: () => {
+ editHandler.onDelete(timeline.name);
+ },
+ onMove: {
+ start: (e) => {
+ if (moveState != null) return;
+ setMoveState({
+ index,
+ offset: 0,
+ startPointY: e.clientY,
+ });
+ },
+ moving: (e) => {
+ if (moveState == null) return;
+ setMoveState({
+ index,
+ offset: e.clientY - moveState.startPointY,
+ startPointY: moveState.startPointY,
+ });
+ },
+ end: () => {
+ if (moveState != null) {
+ const offsetCount = Math.round(
+ moveState.offset / height
+ );
+ editHandler.onMove(
+ timeline.name,
+ moveState.index,
+ offsetCount
+ );
+ }
+ setMoveState(null);
+ },
+ },
+ }
+ : undefined
+ }
+ />
+ );
+ })}
+ </>
+ );
+};
+
+interface TimelineBoardUIProps {
+ title?: string;
+ timelines: HttpTimelineInfo[] | "offline" | "loading";
+ onReload: () => void;
+ className?: string;
+ editHandler?: {
+ onMove: (timeline: string, index: number, offset: number) => void;
+ onDelete: (timeline: string) => void;
+ };
+}
+
+const TimelineBoardUI: React.FC<TimelineBoardUIProps> = (props) => {
+ const { title, timelines, className, editHandler } = props;
+
+ const { t } = useTranslation();
+
+ const editable = editHandler != null;
+
+ const [editing, setEditing] = React.useState<boolean>(false);
+
+ return (
+ <div className={classnames("timeline-board", className)}>
+ <div className="timeline-board-header">
+ {title != null && <h3>{title}</h3>}
+ {editable &&
+ (editing ? (
+ <div
+ className="flat-button text-primary"
+ onClick={() => {
+ setEditing(false);
+ }}
+ >
+ {t("done")}
+ </div>
+ ) : (
+ <div
+ className="flat-button text-primary"
+ onClick={() => {
+ setEditing(true);
+ }}
+ >
+ {t("edit")}
+ </div>
+ ))}
+ </div>
+ {(() => {
+ if (timelines === "loading") {
+ return (
+ <div className="d-flex flex-grow-1 justify-content-center align-items-center">
+ <Spinner variant="primary" animation="border" />
+ </div>
+ );
+ } else if (timelines === "offline") {
+ return (
+ <div className="d-flex flex-grow-1 justify-content-center align-items-center">
+ <LoadFailReload onReload={props.onReload} />
+ </div>
+ );
+ } else {
+ return (
+ <TimelineBoardItemContainer
+ timelines={timelines}
+ editHandler={
+ editHandler && editing
+ ? {
+ onDelete: editHandler.onDelete,
+ onMove: (timeline, index, offset) => {
+ if (index + offset >= timelines.length) {
+ offset = timelines.length - index - 1;
+ } else if (index + offset < 0) {
+ offset = -index;
+ }
+ editHandler.onMove(timeline, index, offset);
+ },
+ }
+ : undefined
+ }
+ />
+ );
+ }
+ })()}
+ </div>
+ );
+};
+
+export interface TimelineBoardProps {
+ title?: string;
+ className?: string;
+ load: () => Promise<HttpTimelineInfo[]>;
+ editHandler?: {
+ onMove: (timeline: string, index: number, offset: number) => Promise<void>;
+ onDelete: (timeline: string) => Promise<void>;
+ };
+}
+
+const TimelineBoard: React.FC<TimelineBoardProps> = ({
+ className,
+ title,
+ load,
+ editHandler,
+}) => {
+ const [timelines, setTimelines] = React.useState<
+ HttpTimelineInfo[] | "offline" | "loading"
+ >("loading");
+
+ React.useEffect(() => {
+ let subscribe = true;
+ if (timelines === "loading") {
+ void load().then(
+ (timelines) => {
+ if (subscribe) {
+ setTimelines(timelines);
+ }
+ },
+ () => {
+ setTimelines("offline");
+ }
+ );
+ }
+ return () => {
+ subscribe = false;
+ };
+ }, [load, timelines]);
+
+ return (
+ <TimelineBoardUI
+ title={title}
+ className={className}
+ timelines={timelines}
+ onReload={() => {
+ setTimelines("loading");
+ }}
+ editHandler={
+ typeof timelines === "object" && editHandler != null
+ ? {
+ onMove: (timeline, index, offset) => {
+ const newTimelines = timelines.slice();
+ const [t] = newTimelines.splice(index, 1);
+ newTimelines.splice(index + offset, 0, t);
+ setTimelines(newTimelines);
+ editHandler.onMove(timeline, index, offset).then(null, () => {
+ setTimelines(timelines);
+ });
+ },
+ onDelete: (timeline) => {
+ const newTimelines = timelines.slice();
+ newTimelines.splice(
+ timelines.findIndex((t) => t.name === timeline),
+ 1
+ );
+ setTimelines(newTimelines);
+ editHandler.onDelete(timeline).then(null, () => {
+ setTimelines(timelines);
+ });
+ },
+ }
+ : undefined
+ }
+ />
+ );
+};
+
+export default TimelineBoard;
diff --git a/FrontEnd/src/views/center/TimelineCreateDialog.tsx b/FrontEnd/src/views/center/TimelineCreateDialog.tsx
new file mode 100644
index 00000000..a2437ae5
--- /dev/null
+++ b/FrontEnd/src/views/center/TimelineCreateDialog.tsx
@@ -0,0 +1,53 @@
+import React from "react";
+import { useHistory } from "react-router";
+
+import { validateTimelineName } from "@/services/timeline";
+import OperationDialog from "../common/OperationDialog";
+import { getHttpTimelineClient, HttpTimelineInfo } from "http/timeline";
+
+interface TimelineCreateDialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => {
+ const history = useHistory();
+
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ themeColor="success"
+ title="home.createDialog.title"
+ inputScheme={
+ [
+ {
+ type: "text",
+ label: "home.createDialog.name",
+ helperText: "home.createDialog.nameFormat",
+ },
+ ] as const
+ }
+ inputValidator={([name]) => {
+ if (name.length === 0) {
+ return { 0: "home.createDialog.noEmpty" };
+ } else if (name.length > 26) {
+ return { 0: "home.createDialog.tooLong" };
+ } else if (!validateTimelineName(name)) {
+ return { 0: "home.createDialog.badFormat" };
+ } else {
+ return null;
+ }
+ }}
+ onProcess={([name]): Promise<HttpTimelineInfo> =>
+ getHttpTimelineClient().postTimeline({ name })
+ }
+ onSuccessAndClose={(timeline: HttpTimelineInfo) => {
+ history.push(`timelines/${timeline.name}`);
+ }}
+ failurePrompt={(e) => `${e as string}`}
+ />
+ );
+};
+
+export default TimelineCreateDialog;
diff --git a/FrontEnd/src/views/center/center.sass b/FrontEnd/src/views/center/center.sass
new file mode 100644
index 00000000..c0dfb9c0
--- /dev/null
+++ b/FrontEnd/src/views/center/center.sass
@@ -0,0 +1,36 @@
+.timeline-board
+ @extend .cru-card
+ @extend .d-flex
+ @extend .flex-column
+ @extend .py-3
+ min-height: 200px
+ height: 100%
+ position: relative
+
+.timeline-board-header
+ @extend .px-3
+ display: flex
+ align-items: center
+ justify-content: space-between
+
+.timeline-board-item
+ font-size: 1.1em
+ @extend .px-3
+ height: 48px
+ transition: background 0.3s
+ display: flex
+ align-items: center
+ .icon
+ height: 1.3em
+ color: black
+ @extend .me-2
+ &:hover
+ background: $gray-300
+ .right
+ display: flex
+ align-items: center
+ flex-shrink: 0
+ .title
+ white-space: nowrap
+ overflow: hidden
+ text-overflow: ellipsis
diff --git a/FrontEnd/src/views/center/index.tsx b/FrontEnd/src/views/center/index.tsx
new file mode 100644
index 00000000..0a2abb2c
--- /dev/null
+++ b/FrontEnd/src/views/center/index.tsx
@@ -0,0 +1,64 @@
+import React from "react";
+import { useHistory } from "react-router";
+import { useTranslation } from "react-i18next";
+import { Row, Container, Button, Col } from "react-bootstrap";
+
+import { useUserLoggedIn } from "@/services/user";
+
+import SearchInput from "../common/SearchInput";
+import CenterBoards from "./CenterBoards";
+import TimelineCreateDialog from "./TimelineCreateDialog";
+
+const HomePage: React.FC = () => {
+ const history = useHistory();
+
+ const { t } = useTranslation();
+
+ const user = useUserLoggedIn();
+
+ const [navText, setNavText] = React.useState<string>("");
+
+ const [dialog, setDialog] = React.useState<"create" | null>(null);
+
+ return (
+ <>
+ <Container>
+ <Row className="my-3 justify-content-center">
+ <Col xs={12} sm={8} lg={6}>
+ <SearchInput
+ className="justify-content-center"
+ value={navText}
+ onChange={setNavText}
+ onButtonClick={() => {
+ history.push(`search?q=${navText}`);
+ }}
+ additionalButton={
+ user != null && (
+ <Button
+ variant="outline-success"
+ onClick={() => {
+ setDialog("create");
+ }}
+ >
+ {t("home.createButton")}
+ </Button>
+ )
+ }
+ />
+ </Col>
+ </Row>
+ <CenterBoards />
+ </Container>
+ {dialog === "create" && (
+ <TimelineCreateDialog
+ open
+ close={() => {
+ setDialog(null);
+ }}
+ />
+ )}
+ </>
+ );
+};
+
+export default HomePage;