aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/app/views/home
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src/app/views/home')
-rw-r--r--FrontEnd/src/app/views/home/BoardWithUser.tsx186
-rw-r--r--FrontEnd/src/app/views/home/BoardWithoutUser.tsx61
-rw-r--r--FrontEnd/src/app/views/home/TimelineBoard.tsx353
-rw-r--r--FrontEnd/src/app/views/home/TimelineCreateDialog.tsx14
-rw-r--r--FrontEnd/src/app/views/home/home.sass20
-rw-r--r--FrontEnd/src/app/views/home/index.tsx2
6 files changed, 480 insertions, 156 deletions
diff --git a/FrontEnd/src/app/views/home/BoardWithUser.tsx b/FrontEnd/src/app/views/home/BoardWithUser.tsx
index bbef835a..8afe440b 100644
--- a/FrontEnd/src/app/views/home/BoardWithUser.tsx
+++ b/FrontEnd/src/app/views/home/BoardWithUser.tsx
@@ -3,98 +3,122 @@ import { Row, Col } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { AuthUser } from "@/services/user";
-import { TimelineInfo } from "@/services/timeline";
+import { pushAlert } from "@/services/alert";
+
+import { getHttpHighlightClient } from "@/http/highlight";
import { getHttpTimelineClient } from "@/http/timeline";
+import { getHttpBookmarkClient } from "@/http/bookmark";
import TimelineBoard from "./TimelineBoard";
-import OfflineBoard from "./OfflineBoard";
const BoardWithUser: React.FC<{ user: AuthUser }> = ({ user }) => {
const { t } = useTranslation();
- const [ownTimelines, setOwnTimelines] = React.useState<
- TimelineInfo[] | "offline" | "loading"
- >("loading");
- const [joinTimelines, setJoinTimelines] = React.useState<
- TimelineInfo[] | "offline" | "loading"
- >("loading");
-
- React.useEffect(() => {
- let subscribe = true;
- if (ownTimelines === "loading") {
- void getHttpTimelineClient()
- .listTimeline({ relate: user.username, relateType: "own" })
- .then(
- (timelines) => {
- if (subscribe) {
- setOwnTimelines(timelines);
- }
- },
- () => {
- setOwnTimelines("offline");
- }
- );
- }
- return () => {
- subscribe = false;
- };
- }, [user, ownTimelines]);
-
- React.useEffect(() => {
- let subscribe = true;
- if (joinTimelines === "loading") {
- void getHttpTimelineClient()
- .listTimeline({ relate: user.username, relateType: "join" })
- .then(
- (timelines) => {
- if (subscribe) {
- setJoinTimelines(timelines);
- }
- },
- () => {
- setJoinTimelines("offline");
- }
- );
- }
- return () => {
- subscribe = false;
- };
- }, [user, joinTimelines]);
-
return (
- <Row className="my-3 justify-content-center">
- {ownTimelines === "offline" && joinTimelines === "offline" ? (
- <Col sm="8" lg="6">
- <OfflineBoard
- onReload={() => {
- setOwnTimelines("loading");
- setJoinTimelines("loading");
+ <>
+ <Row className="my-3 justify-content-center">
+ <Col xs="12" md="6">
+ <TimelineBoard
+ title={t("home.bookmarkTimeline")}
+ load={() => getHttpBookmarkClient().list(user.token)}
+ editHandler={{
+ onDelete: (timeline) => {
+ return getHttpBookmarkClient()
+ .delete(timeline, user.token)
+ .catch((e) => {
+ pushAlert({
+ message: {
+ type: "i18n",
+ key: "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
+ user.token
+ )
+ .catch((e) => {
+ pushAlert({
+ message: {
+ type: "i18n",
+ key: "home.message.moveBookmarkFail",
+ },
+ type: "danger",
+ });
+ throw e;
+ });
+ },
}}
/>
</Col>
- ) : (
- <>
- <Col sm="6" lg="5" className="mb-3 mb-sm-0">
- <TimelineBoard
- title={t("home.ownTimeline")}
- timelines={ownTimelines}
- onReload={() => {
- setOwnTimelines("loading");
- }}
- />
- </Col>
- <Col sm="6" lg="5">
- <TimelineBoard
- title={t("home.joinTimeline")}
- timelines={joinTimelines}
- onReload={() => {
- setJoinTimelines("loading");
- }}
- />
- </Col>
- </>
- )}
- </Row>
+ <Col xs="12" md="6" className="my-3 my-md-0">
+ <TimelineBoard
+ title={t("home.relatedTimeline")}
+ load={() =>
+ getHttpTimelineClient().listTimeline({ relate: user.username })
+ }
+ />
+ </Col>
+ </Row>
+ <Row className="my-3 justify-content-center">
+ <Col xs="12" md="6">
+ <TimelineBoard
+ title={t("home.highlightTimeline")}
+ load={() => getHttpHighlightClient().list()}
+ editHandler={
+ user.hasHighlightTimelineAdministrationPermission
+ ? {
+ onDelete: (timeline) => {
+ return getHttpHighlightClient()
+ .delete(timeline, user.token)
+ .catch((e) => {
+ pushAlert({
+ message: {
+ type: "i18n",
+ key: "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
+ user.token
+ )
+ .catch((e) => {
+ pushAlert({
+ message: {
+ type: "i18n",
+ key: "home.message.moveHighlightFail",
+ },
+ type: "danger",
+ });
+ throw e;
+ });
+ },
+ }
+ : undefined
+ }
+ />
+ </Col>
+ <Col xs="12" md="6" className="my-3 my-md-0">
+ <TimelineBoard
+ title={t("home.publicTimeline")}
+ load={() =>
+ getHttpTimelineClient().listTimeline({ visibility: "Public" })
+ }
+ />
+ </Col>
+ </Row>
+ </>
);
};
diff --git a/FrontEnd/src/app/views/home/BoardWithoutUser.tsx b/FrontEnd/src/app/views/home/BoardWithoutUser.tsx
index 7e30f799..d9c7fcf4 100644
--- a/FrontEnd/src/app/views/home/BoardWithoutUser.tsx
+++ b/FrontEnd/src/app/views/home/BoardWithoutUser.tsx
@@ -1,58 +1,31 @@
import React from "react";
import { Row, Col } from "react-bootstrap";
+import { useTranslation } from "react-i18next";
-import { TimelineInfo } from "@/services/timeline";
+import { getHttpHighlightClient } from "@/http/highlight";
import { getHttpTimelineClient } from "@/http/timeline";
import TimelineBoard from "./TimelineBoard";
-import OfflineBoard from "./OfflineBoard";
const BoardWithoutUser: React.FC = () => {
- const [publicTimelines, setPublicTimelines] = React.useState<
- TimelineInfo[] | "offline" | "loading"
- >("loading");
-
- React.useEffect(() => {
- let subscribe = true;
- if (publicTimelines === "loading") {
- void getHttpTimelineClient()
- .listTimeline({ visibility: "Public" })
- .then(
- (timelines) => {
- if (subscribe) {
- setPublicTimelines(timelines);
- }
- },
- () => {
- setPublicTimelines("offline");
- }
- );
- }
- return () => {
- subscribe = false;
- };
- }, [publicTimelines]);
+ const { t } = useTranslation();
return (
<Row className="my-3 justify-content-center">
- {publicTimelines === "offline" ? (
- <Col sm="8" lg="6">
- <OfflineBoard
- onReload={() => {
- setPublicTimelines("loading");
- }}
- />
- </Col>
- ) : (
- <Col sm="8" lg="6">
- <TimelineBoard
- timelines={publicTimelines}
- onReload={() => {
- setPublicTimelines("loading");
- }}
- />
- </Col>
- )}
+ <Col xs="12" md="6">
+ <TimelineBoard
+ title={t("home.highlightTimeline")}
+ load={() => getHttpHighlightClient().list()}
+ />
+ </Col>
+ <Col xs="12" md="6" className="my-3 my-md-0">
+ <TimelineBoard
+ title={t("home.publicTimeline")}
+ load={() =>
+ getHttpTimelineClient().listTimeline({ visibility: "Public" })
+ }
+ />
+ </Col>
</Row>
);
};
diff --git a/FrontEnd/src/app/views/home/TimelineBoard.tsx b/FrontEnd/src/app/views/home/TimelineBoard.tsx
index c2a7e5fe..c3f01aed 100644
--- a/FrontEnd/src/app/views/home/TimelineBoard.tsx
+++ b/FrontEnd/src/app/views/home/TimelineBoard.tsx
@@ -1,26 +1,254 @@
import React from "react";
import clsx from "clsx";
import { Link } from "react-router-dom";
-import { Trans } from "react-i18next";
+import { Trans, useTranslation } from "react-i18next";
import { Spinner } from "react-bootstrap";
import { TimelineInfo } from "@/services/timeline";
import TimelineLogo from "../common/TimelineLogo";
import UserTimelineLogo from "../common/UserTimelineLogo";
+import { HttpTimelineInfo } from "@/http/timeline";
-export interface TimelineBoardProps {
+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="ml-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 = {
+ translate:
+ arbitraryOffset != null
+ ? `0 ${arbitraryOffset}px`
+ : offset != null
+ ? `0 ${offset * 100}%`
+ : undefined,
+ transition: offset != null ? "translate 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: TimelineInfo[];
+ 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: TimelineInfo[] | "offline" | "loading";
onReload: () => void;
className?: string;
+ editHandler?: {
+ onMove: (timeline: string, index: number, offset: number) => void;
+ onDelete: (timeline: string) => void;
+ };
}
-const TimelineBoard: React.FC<TimelineBoardProps> = (props) => {
- const { title, timelines, className } = props;
+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={clsx("timeline-board", className)}>
- {title != null && <h3 className="text-center">{title}</h3>}
+ <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 (
@@ -47,28 +275,107 @@ const TimelineBoard: React.FC<TimelineBoardProps> = (props) => {
</div>
);
} else {
- return timelines.map((timeline) => {
- const { name, title } = timeline;
- const isPersonal = name.startsWith("@");
- const url = isPersonal
- ? `/users/${timeline.owner.username}`
- : `/timelines/${name}`;
- return (
- <Link key={name} to={url} className="timeline-board-item">
- {isPersonal ? (
- <UserTimelineLogo className="icon" />
- ) : (
- <TimelineLogo className="icon" />
- )}
- {title}
- <small className="ml-2 text-secondary">{name}</small>
- </Link>
- );
- });
+ 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<TimelineInfo[]>;
+ 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<
+ TimelineInfo[] | "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/app/views/home/TimelineCreateDialog.tsx b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx
index 12bbfb54..5dcba612 100644
--- a/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx
+++ b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx
@@ -1,7 +1,11 @@
import React from "react";
import { useHistory } from "react-router";
-import { validateTimelineName, timelineService } from "@/services/timeline";
+import {
+ validateTimelineName,
+ timelineService,
+ TimelineInfo,
+} from "@/services/timeline";
import OperationDialog from "../common/OperationDialog";
interface TimelineCreateDialogProps {
@@ -12,8 +16,6 @@ interface TimelineCreateDialogProps {
const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => {
const history = useHistory();
- let nameSaved: string;
-
return (
<OperationDialog
open={props.open}
@@ -40,11 +42,11 @@ const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => {
return null;
}
}}
- onProcess={([name]) => {
+ onProcess={([name]): Promise<TimelineInfo> => {
return timelineService.createTimeline(name).toPromise();
}}
- onSuccessAndClose={() => {
- history.push(`timelines/${nameSaved}`);
+ onSuccessAndClose={(timeline: TimelineInfo) => {
+ history.push(`timelines/${timeline.name}`);
}}
failurePrompt={(e) => `${e as string}`}
/>
diff --git a/FrontEnd/src/app/views/home/home.sass b/FrontEnd/src/app/views/home/home.sass
index 0c01019b..4b86f241 100644
--- a/FrontEnd/src/app/views/home/home.sass
+++ b/FrontEnd/src/app/views/home/home.sass
@@ -4,15 +4,33 @@
@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
- @extend .py-2
+ height: 48px
transition: background 0.3s
+ display: flex
+ align-items: center
.icon
height: 1.3em
color: black
@extend .mr-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/app/views/home/index.tsx b/FrontEnd/src/app/views/home/index.tsx
index 0d439f36..3c53736d 100644
--- a/FrontEnd/src/app/views/home/index.tsx
+++ b/FrontEnd/src/app/views/home/index.tsx
@@ -33,7 +33,7 @@ const HomePage: React.FC = () => {
return (
<>
- <Container fluid>
+ <Container>
<Row className="my-3 justify-content-center">
<Col xs={12} sm={8} lg={6}>
<SearchInput