From 3fd4375920c7692082f6e8e91d763ec5c0a1d72a Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 16 Apr 2021 17:43:16 +0800 Subject: ... --- FrontEnd/src/app/App.tsx | 8 +- FrontEnd/src/app/index.sass | 2 +- 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 ++++ .../src/app/views/home-v2/TimelineListView.tsx | 101 ------ FrontEnd/src/app/views/home-v2/home-v2.sass | 29 -- FrontEnd/src/app/views/home-v2/index.tsx | 101 ------ FrontEnd/src/app/views/home/BoardWithUser.tsx | 111 ------- FrontEnd/src/app/views/home/BoardWithoutUser.tsx | 33 -- FrontEnd/src/app/views/home/TimelineBoard.tsx | 370 --------------------- .../src/app/views/home/TimelineCreateDialog.tsx | 53 --- FrontEnd/src/app/views/home/TimelineListView.tsx | 101 ++++++ .../src/app/views/home/WebsiteIntroduction.tsx | 69 ++++ FrontEnd/src/app/views/home/home.sass | 57 ++-- FrontEnd/src/app/views/home/index.tsx | 79 +++-- 19 files changed, 930 insertions(+), 858 deletions(-) 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 delete mode 100644 FrontEnd/src/app/views/home-v2/TimelineListView.tsx delete mode 100644 FrontEnd/src/app/views/home-v2/home-v2.sass delete mode 100644 FrontEnd/src/app/views/home-v2/index.tsx delete mode 100644 FrontEnd/src/app/views/home/BoardWithUser.tsx delete mode 100644 FrontEnd/src/app/views/home/BoardWithoutUser.tsx delete mode 100644 FrontEnd/src/app/views/home/TimelineBoard.tsx delete mode 100644 FrontEnd/src/app/views/home/TimelineCreateDialog.tsx create mode 100644 FrontEnd/src/app/views/home/TimelineListView.tsx create mode 100644 FrontEnd/src/app/views/home/WebsiteIntroduction.tsx diff --git a/FrontEnd/src/app/App.tsx b/FrontEnd/src/app/App.tsx index 5c4b7eb2..39ef78f2 100644 --- a/FrontEnd/src/app/App.tsx +++ b/FrontEnd/src/app/App.tsx @@ -3,8 +3,8 @@ import { BrowserRouter as Router, Route, Switch } from "react-router-dom"; import AppBar from "./views/common/AppBar"; import LoadingPage from "./views/common/LoadingPage"; +import Center from "./views/center"; import Home from "./views/home"; -import HomeV2 from "./views/home-v2"; import Login from "./views/login"; import Settings from "./views/settings"; import About from "./views/about"; @@ -40,13 +40,13 @@ const App: React.FC = () => {
- + {user == null ? :
} - - + +
diff --git a/FrontEnd/src/app/index.sass b/FrontEnd/src/app/index.sass index 6527a65f..6f6bafb3 100644 --- a/FrontEnd/src/app/index.sass +++ b/FrontEnd/src/app/index.sass @@ -3,8 +3,8 @@ @import './views/common/common' @import './views/common/alert/alert' +@import './views/center/center' @import './views/home/home' -@import './views/home-v2/home-v2' @import './views/about/about' @import './views/login/login' @import './views/settings/settings' 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; diff --git a/FrontEnd/src/app/views/home-v2/TimelineListView.tsx b/FrontEnd/src/app/views/home-v2/TimelineListView.tsx deleted file mode 100644 index 9c44a0c2..00000000 --- a/FrontEnd/src/app/views/home-v2/TimelineListView.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React from "react"; - -import { convertI18nText, I18nText } from "@/common"; - -import { HttpTimelineInfo } from "@/http/timeline"; -import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; - -interface TimelineListItemProps { - timeline: HttpTimelineInfo; -} - -const TimelineListItem: React.FC = ({ timeline }) => { - const url = React.useMemo( - () => - timeline.name.startsWith("@") - ? `/users/${timeline.owner.username}` - : `/timelines/${timeline.name}`, - [timeline] - ); - - return ( -
- - - -
-
{timeline.title}
-
- {timeline.description} -
-
- - - -
- ); -}; - -const TimelineListArrow: React.FC = () => { - return ( -
-
- - - -
-
- - - -
-
- ); -}; - -interface TimelineListViewProps { - headerText?: I18nText; - timelines?: HttpTimelineInfo[]; -} - -const TimelineListView: React.FC = ({ - headerText, - timelines, -}) => { - const { t } = useTranslation(); - - return ( -
-
- - - -

{convertI18nText(headerText, t)}

-
- {timelines != null - ? timelines.map((t) => ) - : null} - -
- ); -}; - -export default TimelineListView; diff --git a/FrontEnd/src/app/views/home-v2/home-v2.sass b/FrontEnd/src/app/views/home-v2/home-v2.sass deleted file mode 100644 index 56049994..00000000 --- a/FrontEnd/src/app/views/home-v2/home-v2.sass +++ /dev/null @@ -1,29 +0,0 @@ -.home-v2-timeline-list-item - display: flex - align-items: center - -.home-v2-timeline-list-item-timeline - transition: background 0.8s - animation: 0.8s home-v2-timeline-list-item-timeline-enter - &:hover - background: $gray-200 - -@keyframes home-v2-timeline-list-item-timeline-enter - from - transform: translate(-100%,0) - opacity: 0 - -.home-v2-timeline-list-item-line - width: 80px - flex-shrink: 0 - -@keyframes home-v2-timeline-list-loading-head-animation - from - transform: translate(0,-30px) - opacity: 1 - - to - opacity: 0 - -.home-v2-timeline-list-loading-head - animation: 1s infinite home-v2-timeline-list-loading-head-animation diff --git a/FrontEnd/src/app/views/home-v2/index.tsx b/FrontEnd/src/app/views/home-v2/index.tsx deleted file mode 100644 index cb3c1428..00000000 --- a/FrontEnd/src/app/views/home-v2/index.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React from "react"; -import { useHistory } from "react-router"; -import { useTranslation } from "react-i18next"; -import { Container, Button, Row, Col } from "react-bootstrap"; - -import { useUser } from "@/services/user"; -import SearchInput from "../common/SearchInput"; - -import TimelineListView from "./TimelineListView"; -import TimelineCreateDialog from "../home/TimelineCreateDialog"; -import { HttpTimelineInfo } from "@/http/timeline"; -import { getHttpHighlightClient } from "@/http/highlight"; - -const highlightTimelineMessageMap = { - loading: "home.loadingHighlightTimelines", - done: "home.loadedHighlightTimelines", - error: "home.errorHighlightTimelines", -} as const; - -const HomeV2: React.FC = () => { - const history = useHistory(); - - const { t } = useTranslation(); - - const user = useUser(); - - const [navText, setNavText] = React.useState(""); - - const [dialog, setDialog] = React.useState<"create" | null>(null); - - const [highlightTimelineState, setHighlightTimelineState] = React.useState< - "loading" | "done" | "error" - >("loading"); - const [highlightTimelines, setHighlightTimelines] = React.useState< - HttpTimelineInfo[] | undefined - >(); - - React.useEffect(() => { - if (highlightTimelineState === "loading") { - let subscribe = true; - void getHttpHighlightClient() - .list() - .then( - (data) => { - if (subscribe) { - setHighlightTimelineState("done"); - setHighlightTimelines(data); - } - }, - () => { - if (subscribe) { - setHighlightTimelineState("error"); - setHighlightTimelines(undefined); - } - } - ); - return () => { - subscribe = false; - }; - } - }, [highlightTimelineState]); - - return ( - <> - - - - { - history.push(`search?q=${navText}`); - }} - additionalButton={ - user != null && ( - - ) - } - /> - - - - - {dialog === "create" && ( - setDialog(null)} /> - )} - - ); -}; - -export default HomeV2; diff --git a/FrontEnd/src/app/views/home/BoardWithUser.tsx b/FrontEnd/src/app/views/home/BoardWithUser.tsx deleted file mode 100644 index 3263c745..00000000 --- a/FrontEnd/src/app/views/home/BoardWithUser.tsx +++ /dev/null @@ -1,111 +0,0 @@ -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/home/BoardWithoutUser.tsx b/FrontEnd/src/app/views/home/BoardWithoutUser.tsx deleted file mode 100644 index d9c7fcf4..00000000 --- a/FrontEnd/src/app/views/home/BoardWithoutUser.tsx +++ /dev/null @@ -1,33 +0,0 @@ -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/home/TimelineBoard.tsx b/FrontEnd/src/app/views/home/TimelineBoard.tsx deleted file mode 100644 index e0511422..00000000 --- a/FrontEnd/src/app/views/home/TimelineBoard.tsx +++ /dev/null @@ -1,370 +0,0 @@ -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/home/TimelineCreateDialog.tsx b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx deleted file mode 100644 index b4e25ba1..00000000 --- a/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx +++ /dev/null @@ -1,53 +0,0 @@ -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/home/TimelineListView.tsx b/FrontEnd/src/app/views/home/TimelineListView.tsx new file mode 100644 index 00000000..9c44a0c2 --- /dev/null +++ b/FrontEnd/src/app/views/home/TimelineListView.tsx @@ -0,0 +1,101 @@ +import React from "react"; + +import { convertI18nText, I18nText } from "@/common"; + +import { HttpTimelineInfo } from "@/http/timeline"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; + +interface TimelineListItemProps { + timeline: HttpTimelineInfo; +} + +const TimelineListItem: React.FC = ({ timeline }) => { + const url = React.useMemo( + () => + timeline.name.startsWith("@") + ? `/users/${timeline.owner.username}` + : `/timelines/${timeline.name}`, + [timeline] + ); + + return ( +
+ + + +
+
{timeline.title}
+
+ {timeline.description} +
+
+ + + +
+ ); +}; + +const TimelineListArrow: React.FC = () => { + return ( +
+
+ + + +
+
+ + + +
+
+ ); +}; + +interface TimelineListViewProps { + headerText?: I18nText; + timelines?: HttpTimelineInfo[]; +} + +const TimelineListView: React.FC = ({ + headerText, + timelines, +}) => { + const { t } = useTranslation(); + + return ( +
+
+ + + +

{convertI18nText(headerText, t)}

+
+ {timelines != null + ? timelines.map((t) => ) + : null} + +
+ ); +}; + +export default TimelineListView; diff --git a/FrontEnd/src/app/views/home/WebsiteIntroduction.tsx b/FrontEnd/src/app/views/home/WebsiteIntroduction.tsx new file mode 100644 index 00000000..f4ceebcc --- /dev/null +++ b/FrontEnd/src/app/views/home/WebsiteIntroduction.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +const WebsiteIntroduction: React.FC<{ + className?: string; + style?: React.CSSProperties; +}> = ({ className, style }) => { + const { i18n } = useTranslation(); + + if (i18n.language.startsWith("zh")) { + return ( +
+

欢迎来到时间线!🎉🎉🎉

+

+ 本网站由无数个独立的时间线构成,每一个时间线都是一个消息列表,类似于一个聊天软件(比如QQ)。 +

+

+ 如果你拥有一个账号,登陆后你可以自由地在属于你的时间线中发送内容,支持markdown和上传图片哦!你可以创建一个新的时间线来开启一个新的话题。你也可以设置相关权限,只让一部分人能看到时间线的内容。 +

+

+ 如果你没有账号,那么你可以去浏览一下公开的时间线,比如下面这些站长设置的高光时间线。 +

+

+ 鉴于这个网站在我的小型服务器上部署,所以没有开放注册。如果你也想把这个服务部署到自己的服务器上,你可以在关于页面找到一些信息。 +

+

+ + 这一段介绍是我的对象抱怨多次我的网站他根本看不明白之后加的,希望你能顺利看懂这个网站的逻辑!😅 + +

+
+ ); + } else { + return ( +
+

Welcome to Timeline!🎉🎉🎉

+

+ This website consists of many individual timelines. Each timeline is a + list of messages just like a chat app. +

+

+ If you do have an account, you can post messages, which supports + Markdown and images, in your timelines after logging in. You can also + create a new timeline to open a new topic. You can set the permission + of a timeline to only allow specified people to see the content of the + timeline. +

+

+ If you don't have an account, you can view some public timelines + like highlight timelines below set by website manager. +

+

+ Since this website is hosted on my tiny server, so account registry is + not opened. If you want to host this service on your own server, you + can find some useful information on about page. +

+

+ + This introduction is added after my lover complained a lot of times + about the obscuration of my website. May you understand the logic of + it!😅 + +

+
+ ); + } +}; + +export default WebsiteIntroduction; diff --git a/FrontEnd/src/app/views/home/home.sass b/FrontEnd/src/app/views/home/home.sass index 4b86f241..56049994 100644 --- a/FrontEnd/src/app/views/home/home.sass +++ b/FrontEnd/src/app/views/home/home.sass @@ -1,36 +1,29 @@ -.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 +.home-v2-timeline-list-item 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 +.home-v2-timeline-list-item-timeline + transition: background 0.8s + animation: 0.8s home-v2-timeline-list-item-timeline-enter &:hover - background: $gray-300 - .right - display: flex - align-items: center - flex-shrink: 0 - .title - white-space: nowrap - overflow: hidden - text-overflow: ellipsis + background: $gray-200 + +@keyframes home-v2-timeline-list-item-timeline-enter + from + transform: translate(-100%,0) + opacity: 0 + +.home-v2-timeline-list-item-line + width: 80px + flex-shrink: 0 + +@keyframes home-v2-timeline-list-loading-head-animation + from + transform: translate(0,-30px) + opacity: 1 + + to + opacity: 0 + +.home-v2-timeline-list-loading-head + animation: 1s infinite home-v2-timeline-list-loading-head-animation diff --git a/FrontEnd/src/app/views/home/index.tsx b/FrontEnd/src/app/views/home/index.tsx index bcf6ad6e..a0df6a5a 100644 --- a/FrontEnd/src/app/views/home/index.tsx +++ b/FrontEnd/src/app/views/home/index.tsx @@ -1,16 +1,25 @@ import React from "react"; import { useHistory } from "react-router"; import { useTranslation } from "react-i18next"; -import { Row, Container, Button, Col } from "react-bootstrap"; +import { Container, Button, Row, Col } from "react-bootstrap"; + +import { HttpTimelineInfo } from "@/http/timeline"; +import { getHttpHighlightClient } from "@/http/highlight"; import { useUser } from "@/services/user"; + import SearchInput from "../common/SearchInput"; +import TimelineCreateDialog from "../center/TimelineCreateDialog"; +import TimelineListView from "./TimelineListView"; +import WebsiteIntroduction from "./WebsiteIntroduction"; -import BoardWithoutUser from "./BoardWithoutUser"; -import BoardWithUser from "./BoardWithUser"; -import TimelineCreateDialog from "./TimelineCreateDialog"; +const highlightTimelineMessageMap = { + loading: "home.loadingHighlightTimelines", + done: "home.loadedHighlightTimelines", + error: "home.errorHighlightTimelines", +} as const; -const HomePage: React.FC = () => { +const HomeV2: React.FC = () => { const history = useHistory(); const { t } = useTranslation(); @@ -21,13 +30,44 @@ const HomePage: React.FC = () => { const [dialog, setDialog] = React.useState<"create" | null>(null); + const [highlightTimelineState, setHighlightTimelineState] = React.useState< + "loading" | "done" | "error" + >("loading"); + const [highlightTimelines, setHighlightTimelines] = React.useState< + HttpTimelineInfo[] | undefined + >(); + + React.useEffect(() => { + if (highlightTimelineState === "loading") { + let subscribe = true; + void getHttpHighlightClient() + .list() + .then( + (data) => { + if (subscribe) { + setHighlightTimelineState("done"); + setHighlightTimelines(data); + } + }, + () => { + if (subscribe) { + setHighlightTimelineState("error"); + setHighlightTimelines(undefined); + } + } + ); + return () => { + subscribe = false; + }; + } + }, [highlightTimelineState]); + return ( <> - - - + + + { @@ -48,24 +88,17 @@ const HomePage: React.FC = () => { /> - {(() => { - if (user == null) { - return ; - } else { - return ; - } - })()} + + {dialog === "create" && ( - { - setDialog(null); - }} - /> + setDialog(null)} /> )} ); }; -export default HomePage; +export default HomeV2; -- cgit v1.2.3