From 3fd4375920c7692082f6e8e91d763ec5c0a1d72a Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 16 Apr 2021 17:43:16 +0800 Subject: ... --- FrontEnd/src/app/views/center/BoardWithUser.tsx | 111 +++++++ FrontEnd/src/app/views/center/BoardWithoutUser.tsx | 33 ++ FrontEnd/src/app/views/center/TimelineBoard.tsx | 370 +++++++++++++++++++++ .../src/app/views/center/TimelineCreateDialog.tsx | 53 +++ FrontEnd/src/app/views/center/center.sass | 36 ++ FrontEnd/src/app/views/center/index.tsx | 71 ++++ 6 files changed, 674 insertions(+) create mode 100644 FrontEnd/src/app/views/center/BoardWithUser.tsx create mode 100644 FrontEnd/src/app/views/center/BoardWithoutUser.tsx create mode 100644 FrontEnd/src/app/views/center/TimelineBoard.tsx create mode 100644 FrontEnd/src/app/views/center/TimelineCreateDialog.tsx create mode 100644 FrontEnd/src/app/views/center/center.sass create mode 100644 FrontEnd/src/app/views/center/index.tsx (limited to 'FrontEnd/src/app/views/center') diff --git a/FrontEnd/src/app/views/center/BoardWithUser.tsx b/FrontEnd/src/app/views/center/BoardWithUser.tsx new file mode 100644 index 00000000..3263c745 --- /dev/null +++ b/FrontEnd/src/app/views/center/BoardWithUser.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import { Row, Col } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +import { AuthUser } from "@/services/user"; +import { pushAlert } from "@/services/alert"; + +import { getHttpHighlightClient } from "@/http/highlight"; +import { getHttpTimelineClient } from "@/http/timeline"; +import { getHttpBookmarkClient } from "@/http/bookmark"; + +import TimelineBoard from "./TimelineBoard"; + +const BoardWithUser: React.FC<{ user: AuthUser }> = ({ user }) => { + const { t } = useTranslation(); + + return ( + <> + + + 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; + }); + }, + }} + /> + + + + getHttpTimelineClient().listTimeline({ relate: user.username }) + } + /> + + + + + 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 + } + /> + + + + getHttpTimelineClient().listTimeline({ visibility: "Public" }) + } + /> + + + + ); +}; + +export default BoardWithUser; diff --git a/FrontEnd/src/app/views/center/BoardWithoutUser.tsx b/FrontEnd/src/app/views/center/BoardWithoutUser.tsx new file mode 100644 index 00000000..d9c7fcf4 --- /dev/null +++ b/FrontEnd/src/app/views/center/BoardWithoutUser.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { Row, Col } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +import { getHttpHighlightClient } from "@/http/highlight"; +import { getHttpTimelineClient } from "@/http/timeline"; + +import TimelineBoard from "./TimelineBoard"; + +const BoardWithoutUser: React.FC = () => { + const { t } = useTranslation(); + + return ( + + + getHttpHighlightClient().list()} + /> + + + + getHttpTimelineClient().listTimeline({ visibility: "Public" }) + } + /> + + + ); +}; + +export default BoardWithoutUser; diff --git a/FrontEnd/src/app/views/center/TimelineBoard.tsx b/FrontEnd/src/app/views/center/TimelineBoard.tsx new file mode 100644 index 00000000..e0511422 --- /dev/null +++ b/FrontEnd/src/app/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 = ({ + timeline, + arbitraryOffset, + offset, + actions, +}) => { + const { name, title } = timeline; + const isPersonal = name.startsWith("@"); + const url = isPersonal + ? `/users/${timeline.owner.username}` + : `/timelines/${name}`; + + const content = ( + <> + {isPersonal ? ( + + ) : ( + + )} + {title} + {name} + + {actions != null ? ( +
+ + { + 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} + /> +
+ ) : 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 ? ( + + {content} + + ) : ( +
+ {content} +
+ ); +}; + +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 = ({ + timelines, + editHandler, +}) => { + const [moveState, setMoveState] = React.useState(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 ( + { + 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 = (props) => { + const { title, timelines, className, editHandler } = props; + + const { t } = useTranslation(); + + const editable = editHandler != null; + + const [editing, setEditing] = React.useState(false); + + return ( +
+
+ {title != null &&

{title}

} + {editable && + (editing ? ( +
{ + setEditing(false); + }} + > + {t("done")} +
+ ) : ( +
{ + setEditing(true); + }} + > + {t("edit")} +
+ ))} +
+ {(() => { + if (timelines === "loading") { + return ( +
+ +
+ ); + } else if (timelines === "offline") { + return ( +
+ +
+ ); + } else { + return ( + { + if (index + offset >= timelines.length) { + offset = timelines.length - index - 1; + } else if (index + offset < 0) { + offset = -index; + } + editHandler.onMove(timeline, index, offset); + }, + } + : undefined + } + /> + ); + } + })()} +
+ ); +}; + +export interface TimelineBoardProps { + title?: string; + className?: string; + load: () => Promise; + editHandler?: { + onMove: (timeline: string, index: number, offset: number) => Promise; + onDelete: (timeline: string) => Promise; + }; +} + +const TimelineBoard: React.FC = ({ + 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 ( + { + 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/center/TimelineCreateDialog.tsx b/FrontEnd/src/app/views/center/TimelineCreateDialog.tsx new file mode 100644 index 00000000..b4e25ba1 --- /dev/null +++ b/FrontEnd/src/app/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 = (props) => { + const history = useHistory(); + + return ( + { + 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 => + getHttpTimelineClient().postTimeline({ name }) + } + onSuccessAndClose={(timeline: HttpTimelineInfo) => { + history.push(`timelines/${timeline.name}`); + }} + failurePrompt={(e) => `${e as string}`} + /> + ); +}; + +export default TimelineCreateDialog; diff --git a/FrontEnd/src/app/views/center/center.sass b/FrontEnd/src/app/views/center/center.sass new file mode 100644 index 00000000..4b86f241 --- /dev/null +++ b/FrontEnd/src/app/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 .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/center/index.tsx b/FrontEnd/src/app/views/center/index.tsx new file mode 100644 index 00000000..bcf6ad6e --- /dev/null +++ b/FrontEnd/src/app/views/center/index.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { useHistory } from "react-router"; +import { useTranslation } from "react-i18next"; +import { Row, Container, Button, Col } from "react-bootstrap"; + +import { useUser } from "@/services/user"; +import SearchInput from "../common/SearchInput"; + +import BoardWithoutUser from "./BoardWithoutUser"; +import BoardWithUser from "./BoardWithUser"; +import TimelineCreateDialog from "./TimelineCreateDialog"; + +const HomePage: React.FC = () => { + const history = useHistory(); + + const { t } = useTranslation(); + + const user = useUser(); + + const [navText, setNavText] = React.useState(""); + + const [dialog, setDialog] = React.useState<"create" | null>(null); + + return ( + <> + + + + { + history.push(`search?q=${navText}`); + }} + additionalButton={ + user != null && ( + + ) + } + /> + + + {(() => { + if (user == null) { + return ; + } else { + return ; + } + })()} + + {dialog === "create" && ( + { + setDialog(null); + }} + /> + )} + + ); +}; + +export default HomePage; -- cgit v1.2.3