diff options
Diffstat (limited to 'FrontEnd/src/app/views/home')
-rw-r--r-- | FrontEnd/src/app/views/home/BoardWithUser.tsx | 186 | ||||
-rw-r--r-- | FrontEnd/src/app/views/home/BoardWithoutUser.tsx | 61 | ||||
-rw-r--r-- | FrontEnd/src/app/views/home/TimelineBoard.tsx | 353 | ||||
-rw-r--r-- | FrontEnd/src/app/views/home/TimelineCreateDialog.tsx | 14 | ||||
-rw-r--r-- | FrontEnd/src/app/views/home/home.sass | 20 | ||||
-rw-r--r-- | FrontEnd/src/app/views/home/index.tsx | 2 |
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 |