From 3f07b047df4d66f83047a5bb747c0a1665bceb6c Mon Sep 17 00:00:00 2001 From: crupest Date: Sun, 1 May 2022 21:30:31 +0800 Subject: ... --- FrontEnd/src/utilities/hooks.ts | 10 +- .../hooks/useReverseScrollPositionRemember.ts | 2 + FrontEnd/src/utilities/hooks/useScrollToBottom.ts | 45 ++++ FrontEnd/src/utilities/hooks/useScrollToTop.ts | 43 ---- FrontEnd/src/views/timeline/Timeline.css | 246 +++++++++++++++++++++ FrontEnd/src/views/timeline/Timeline.tsx | 128 ++++++----- .../views/timeline/TimelinePagedPostListView.tsx | 34 --- FrontEnd/src/views/timeline/TimelinePostEdit.css | 8 +- .../src/views/timeline/TimelinePostEditCard.tsx | 2 +- FrontEnd/src/views/timeline/index.css | 246 --------------------- FrontEnd/src/views/timeline/index.tsx | 4 - 11 files changed, 366 insertions(+), 402 deletions(-) create mode 100644 FrontEnd/src/utilities/hooks/useScrollToBottom.ts delete mode 100644 FrontEnd/src/utilities/hooks/useScrollToTop.ts create mode 100644 FrontEnd/src/views/timeline/Timeline.css delete mode 100644 FrontEnd/src/views/timeline/TimelinePagedPostListView.tsx delete mode 100644 FrontEnd/src/views/timeline/index.css (limited to 'FrontEnd/src') diff --git a/FrontEnd/src/utilities/hooks.ts b/FrontEnd/src/utilities/hooks.ts index c499b36b..a59f7167 100644 --- a/FrontEnd/src/utilities/hooks.ts +++ b/FrontEnd/src/utilities/hooks.ts @@ -1,11 +1,5 @@ import useClickOutside from "./hooks/useClickOutside"; -import useReverseScrollPositionRemember from "./hooks/useReverseScrollPositionRemember"; -import useScrollToTop from "./hooks/useScrollToTop"; +import useScrollToBottom from "./hooks/useScrollToBottom"; import { useIsSmallScreen } from "./hooks/mediaQuery"; -export { - useClickOutside, - useReverseScrollPositionRemember, - useScrollToTop, - useIsSmallScreen, -}; +export { useClickOutside, useScrollToBottom, useIsSmallScreen }; diff --git a/FrontEnd/src/utilities/hooks/useReverseScrollPositionRemember.ts b/FrontEnd/src/utilities/hooks/useReverseScrollPositionRemember.ts index 6fdd4b43..c0b6ce2c 100644 --- a/FrontEnd/src/utilities/hooks/useReverseScrollPositionRemember.ts +++ b/FrontEnd/src/utilities/hooks/useReverseScrollPositionRemember.ts @@ -1,3 +1,5 @@ +// Not used now!!! But preserved for future use. + import React from "react"; let on = false; diff --git a/FrontEnd/src/utilities/hooks/useScrollToBottom.ts b/FrontEnd/src/utilities/hooks/useScrollToBottom.ts new file mode 100644 index 00000000..f6780d9f --- /dev/null +++ b/FrontEnd/src/utilities/hooks/useScrollToBottom.ts @@ -0,0 +1,45 @@ +import React from "react"; +import { fromEvent } from "rxjs"; +import { filter, throttleTime } from "rxjs/operators"; + +function useScrollToBottom( + handler: () => void, + enable = true, + option = { + maxOffset: 5, + throttle: 1000, + } +): void { + const handlerRef = React.useRef<(() => void) | null>(null); + + React.useEffect(() => { + handlerRef.current = handler; + + return () => { + handlerRef.current = null; + }; + }, [handler]); + + React.useEffect(() => { + const subscription = fromEvent(window, "scroll") + .pipe( + filter( + () => + window.scrollY >= + document.body.scrollHeight - window.innerHeight - option.maxOffset + ), + throttleTime(option.throttle) + ) + .subscribe(() => { + if (enable) { + handlerRef.current?.(); + } + }); + + return () => { + subscription.unsubscribe(); + }; + }, [enable, option.maxOffset, option.throttle]); +} + +export default useScrollToBottom; diff --git a/FrontEnd/src/utilities/hooks/useScrollToTop.ts b/FrontEnd/src/utilities/hooks/useScrollToTop.ts deleted file mode 100644 index 95c8b7b9..00000000 --- a/FrontEnd/src/utilities/hooks/useScrollToTop.ts +++ /dev/null @@ -1,43 +0,0 @@ -import React from "react"; -import { fromEvent } from "rxjs"; -import { filter, throttleTime } from "rxjs/operators"; - -function useScrollToTop( - handler: () => void, - enable = true, - option = { - maxOffset: 5, - throttle: 1000, - } -): void { - const handlerRef = React.useRef<(() => void) | null>(null); - - React.useEffect(() => { - handlerRef.current = handler; - - return () => { - handlerRef.current = null; - }; - }, [handler]); - - React.useEffect(() => { - const subscription = fromEvent(window, "scroll") - .pipe( - filter(() => { - return window.scrollY <= option.maxOffset; - }), - throttleTime(option.throttle) - ) - .subscribe(() => { - if (enable) { - handlerRef.current?.(); - } - }); - - return () => { - subscription.unsubscribe(); - }; - }, [enable, option.maxOffset, option.throttle]); -} - -export default useScrollToTop; diff --git a/FrontEnd/src/views/timeline/Timeline.css b/FrontEnd/src/views/timeline/Timeline.css new file mode 100644 index 00000000..fa3542dd --- /dev/null +++ b/FrontEnd/src/views/timeline/Timeline.css @@ -0,0 +1,246 @@ +@keyframes timeline-enter { + from { + transform: translate(0, -100%); + } +} + +.timeline { + z-index: 0; + position: relative; + width: 100%; + animation: 1s timeline-enter; +} + +@keyframes timeline-line-node-noncurrent { + to { + box-shadow: 0 0 20px 3px var(--cru-primary-l1-color); + } +} + +@keyframes timeline-line-node-current { + to { + box-shadow: 0 0 20px 3px var(--cru-primary-enhance-l1-color); + } +} + +@keyframes timeline-line-node-loading { + to { + box-shadow: 0 0 20px 3px var(--cru-primary-l1-color); + } +} + +@keyframes timeline-line-node-loading-edge { + from { + transform: rotate(0turn); + } + to { + transform: rotate(1turn); + } +} + +@keyframes timeline-top-loading-enter { + from { + transform: translate(0, -100%); + } +} + +@keyframes timeline-post-enter { + from { + transform: translate(0, -100%); + opacity: 0; + } + to { + opacity: 1; + } +} + +.timeline-top-loading-enter { + animation: 1s timeline-top-loading-enter; +} + +.timeline-line { + display: flex; + flex-direction: column; + align-items: center; + width: 30px; + position: absolute; + z-index: 1; + left: 2em; + top: 0; + bottom: 0; + transition: left 0.5s; +} + +@media (max-width: 575.98px) { + .timeline-line { + left: 1em; + } +} + +.timeline-line .segment { + width: 7px; + background: var(--cru-primary-color); +} +.timeline-line .segment.start { + height: 1.8em; + flex: 0 0 auto; +} +.timeline-line .segment.end { + flex: 1 1 auto; +} +.timeline-line .segment.current-end { + height: 2em; + flex: 0 0 auto; + background: linear-gradient(var(--cru-primary-enhance-color), white); +} +.timeline-line .node-container { + flex: 0 0 auto; + position: relative; + width: 18px; + height: 18px; +} +.timeline-line .node { + width: 20px; + height: 20px; + position: absolute; + background: var(--cru-primary-color); + left: -1px; + top: -1px; + border-radius: 50%; + box-sizing: border-box; + z-index: 1; + animation: 1s infinite alternate; + animation-name: timeline-line-node-noncurrent; +} +.timeline-line .node-loading-edge { + color: var(--cru-primary-color); + width: 38px; + height: 38px; + position: absolute; + left: -10px; + top: -10px; + box-sizing: border-box; + z-index: 2; + animation: 1.5s linear infinite timeline-line-node-loading-edge; +} +.timeline-line.current .segment.start { + background: linear-gradient( + var(--cru-primary-color), + var(--cru-primary-enhance-color) + ); +} + +.timeline-line.current .segment.end { + background: var(--cru-primary-enhance-color); +} + +.timeline-line.current .node { + background: var(--cru-primary-enhance-color); + animation-name: timeline-line-node-current; +} + +.timeline-line.loading .node { + background: var(--cru-primary-color); + animation-name: timeline-line-node-loading; +} + +.timeline-item { + position: relative; + padding: 0.5em; +} + +.timeline-item-card { + position: relative; + padding: 0.3em 0.5em 1em 4em; +} + +.timeline-item-card.enter-animation { + animation: 0.6s forwards; + opacity: 0; +} + +@media (max-width: 575.98px) { + .timeline-item-card { + padding-left: 3em; + } +} + +.timeline-item-header { + display: flex; + align-items: center; +} + +.timeline-avatar { + border-radius: 50%; + width: 2em; + height: 2em; +} + +.timeline-item-delete-button { + position: absolute; + right: 0; + bottom: 0; +} + +.timeline-content { + white-space: pre-line; +} + +.timeline-content-image { + max-width: 80%; + max-height: 200px; +} + +.timeline-date-item { + position: relative; + padding: 0.3em 0 0.3em 4em; +} + +.timeline-date-item-badge { + display: inline-block; + padding: 0.1em 0.4em; + border-radius: 0.4em; + background: #7c7c7c; + color: white; + font-size: 0.8em; +} + +.timeline-post-item-options-mask { + background: rgba(255, 255, 255, 0.85); + z-index: 100; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + + display: flex; + justify-content: space-around; + align-items: center; + + border-radius: var(--cru-card-border-radius); +} + +.timeline-sync-state-badge { + font-size: 0.8em; + padding: 3px 8px; + border-radius: 5px; + background: #e8fbff; +} + +.timeline-sync-state-badge-pin { + display: inline-block; + width: 0.4em; + height: 0.4em; + border-radius: 50%; + vertical-align: middle; + margin-right: 0.6em; +} + +.timeline-card { + position: fixed; + z-index: 1029; + top: 56px; + right: 0; + margin: 0.5em; +} diff --git a/FrontEnd/src/views/timeline/Timeline.tsx b/FrontEnd/src/views/timeline/Timeline.tsx index 6399b6bc..84624313 100644 --- a/FrontEnd/src/views/timeline/Timeline.tsx +++ b/FrontEnd/src/views/timeline/Timeline.tsx @@ -1,5 +1,6 @@ import React from "react"; import classnames from "classnames"; +import { useScrollToBottom } from "@/utilities/hooks"; import { HubConnectionState } from "@microsoft/signalr"; import { @@ -16,14 +17,14 @@ import { import { useUser } from "@/services/user"; import { getTimelinePostUpdate$ } from "@/services/timeline"; -import TimelinePagedPostListView from "./TimelinePagedPostListView"; +import TimelinePostListView from "./TimelinePostListView"; import TimelineEmptyItem from "./TimelineEmptyItem"; import TimelineLoading from "./TimelineLoading"; import TimelinePostEdit from "./TimelinePostEdit"; import TimelinePostEditNoLogin from "./TimelinePostEditNoLogin"; import TimelineCard from "./TimelineCard"; -import "./index.css"; +import "./Timeline.css"; export interface TimelineProps { className?: string; @@ -46,6 +47,9 @@ const Timeline: React.FC = (props) => { "offline" | "forbid" | "notfound" | "error" | null >(null); + const [currentPage, setCurrentPage] = React.useState(1); + const [totalPage, setTotalPage] = React.useState(0); + const [timelineReloadKey, setTimelineReloadKey] = React.useState(0); const [postsReloadKey, setPostsReloadKey] = React.useState(0); @@ -60,71 +64,50 @@ const Timeline: React.FC = (props) => { }, [timelineOwner, timelineName]); React.useEffect(() => { - if (timelineName != null) { - let subscribe = true; - - getHttpTimelineClient() - .getTimeline(timelineOwner, timelineName) - .then( - (t) => { - if (subscribe) { - setTimeline(t); - } - }, - (error) => { - if (subscribe) { - if (error instanceof HttpNetworkError) { - setError("offline"); - } else if (error instanceof HttpForbiddenError) { - setError("forbid"); - } else if (error instanceof HttpNotFoundError) { - setError("notfound"); - } else { - console.error(error); - setError("error"); - } - } + getHttpTimelineClient() + .getTimeline(timelineOwner, timelineName) + .then( + (t) => { + setTimeline(t); + }, + (error) => { + if (error instanceof HttpNetworkError) { + setError("offline"); + } else if (error instanceof HttpForbiddenError) { + setError("forbid"); + } else if (error instanceof HttpNotFoundError) { + setError("notfound"); + } else { + console.error(error); + setError("error"); } - ); - - return () => { - subscribe = false; - }; - } + } + ); }, [timelineOwner, timelineName, timelineReloadKey]); React.useEffect(() => { - let subscribe = true; - void getHttpTimelineClient() - .listPost(timelineOwner, timelineName) + getHttpTimelineClient() + .listPost(timelineOwner, timelineName, 1) .then( - (ps) => { - if (subscribe) { - setPosts( - ps.items.filter( - (p): p is HttpTimelinePostInfo => p.deleted === false - ) - ); - } + (page) => { + setPosts( + page.items.filter((p): p is HttpTimelinePostInfo => !p.deleted) + ); + setTotalPage(page.totalPageCount); }, (error) => { - if (subscribe) { - if (error instanceof HttpNetworkError) { - setError("offline"); - } else if (error instanceof HttpForbiddenError) { - setError("forbid"); - } else if (error instanceof HttpNotFoundError) { - setError("notfound"); - } else { - console.error(error); - setError("error"); - } + if (error instanceof HttpNetworkError) { + setError("offline"); + } else if (error instanceof HttpForbiddenError) { + setError("forbid"); + } else if (error instanceof HttpNotFoundError) { + setError("notfound"); + } else { + console.error(error); + setError("error"); } } ); - return () => { - subscribe = false; - }; }, [timelineOwner, timelineName, postsReloadKey]); React.useEffect(() => { @@ -143,6 +126,33 @@ const Timeline: React.FC = (props) => { }; }, [timelineOwner, timelineName]); + useScrollToBottom(() => { + console.log(`Load page ${currentPage + 1}.`); + setCurrentPage(currentPage + 1); + void getHttpTimelineClient() + .listPost(timelineOwner, timelineName, currentPage + 1) + .then( + (page) => { + const ps = page.items.filter( + (p): p is HttpTimelinePostInfo => !p.deleted + ); + setPosts((old) => [...(old ?? []), ...ps]); + }, + (error) => { + if (error instanceof HttpNetworkError) { + setError("offline"); + } else if (error instanceof HttpForbiddenError) { + setError("forbid"); + } else if (error instanceof HttpNotFoundError) { + setError("notfound"); + } else { + console.error(error); + setError("error"); + } + } + ); + }, currentPage < totalPage); + if (error === "offline") { return (
@@ -181,8 +191,7 @@ const Timeline: React.FC = (props) => { )} {posts && (
- - + {timeline?.postable ? ( ) : user == null ? ( @@ -190,6 +199,7 @@ const Timeline: React.FC = (props) => { ) : ( )} +
)} diff --git a/FrontEnd/src/views/timeline/TimelinePagedPostListView.tsx b/FrontEnd/src/views/timeline/TimelinePagedPostListView.tsx deleted file mode 100644 index 6a0ad0f5..00000000 --- a/FrontEnd/src/views/timeline/TimelinePagedPostListView.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from "react"; - -import { HttpTimelinePostInfo } from "@/http/timeline"; - -import { useScrollToTop } from "@/utilities/hooks"; - -import TimelinePostListView from "./TimelinePostListView"; - -export interface TimelinePagedPostListViewProps { - posts: HttpTimelinePostInfo[]; - onReload: () => void; -} - -const TimelinePagedPostListView: React.FC = ( - props -) => { - const { posts, onReload } = props; - - const [lastViewCount, setLastViewCount] = React.useState(10); - - const viewingPosts = React.useMemo(() => { - return lastViewCount >= posts.length - ? posts.slice() - : posts.slice(-lastViewCount); - }, [posts, lastViewCount]); - - useScrollToTop(() => { - setLastViewCount(lastViewCount + 10); - }, lastViewCount < posts.length); - - return ; -}; - -export default TimelinePagedPostListView; diff --git a/FrontEnd/src/views/timeline/TimelinePostEdit.css b/FrontEnd/src/views/timeline/TimelinePostEdit.css index 4ce98383..fb34e673 100644 --- a/FrontEnd/src/views/timeline/TimelinePostEdit.css +++ b/FrontEnd/src/views/timeline/TimelinePostEdit.css @@ -2,15 +2,9 @@ padding-bottom: 0; } -.timeline-post-edit .timeline-item-card { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - border-bottom: none; -} - .timeline-post-edit { position: sticky !important; - bottom: 0; + top: 0; z-index: 1; } diff --git a/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx b/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx index a69d413a..de0e7e43 100644 --- a/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx +++ b/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx @@ -22,7 +22,7 @@ const TimelinePostEdit: React.FC = ({ className={classnames("timeline-item timeline-post-edit", className)} style={style} > - + {children}
); diff --git a/FrontEnd/src/views/timeline/index.css b/FrontEnd/src/views/timeline/index.css deleted file mode 100644 index fa3542dd..00000000 --- a/FrontEnd/src/views/timeline/index.css +++ /dev/null @@ -1,246 +0,0 @@ -@keyframes timeline-enter { - from { - transform: translate(0, -100%); - } -} - -.timeline { - z-index: 0; - position: relative; - width: 100%; - animation: 1s timeline-enter; -} - -@keyframes timeline-line-node-noncurrent { - to { - box-shadow: 0 0 20px 3px var(--cru-primary-l1-color); - } -} - -@keyframes timeline-line-node-current { - to { - box-shadow: 0 0 20px 3px var(--cru-primary-enhance-l1-color); - } -} - -@keyframes timeline-line-node-loading { - to { - box-shadow: 0 0 20px 3px var(--cru-primary-l1-color); - } -} - -@keyframes timeline-line-node-loading-edge { - from { - transform: rotate(0turn); - } - to { - transform: rotate(1turn); - } -} - -@keyframes timeline-top-loading-enter { - from { - transform: translate(0, -100%); - } -} - -@keyframes timeline-post-enter { - from { - transform: translate(0, -100%); - opacity: 0; - } - to { - opacity: 1; - } -} - -.timeline-top-loading-enter { - animation: 1s timeline-top-loading-enter; -} - -.timeline-line { - display: flex; - flex-direction: column; - align-items: center; - width: 30px; - position: absolute; - z-index: 1; - left: 2em; - top: 0; - bottom: 0; - transition: left 0.5s; -} - -@media (max-width: 575.98px) { - .timeline-line { - left: 1em; - } -} - -.timeline-line .segment { - width: 7px; - background: var(--cru-primary-color); -} -.timeline-line .segment.start { - height: 1.8em; - flex: 0 0 auto; -} -.timeline-line .segment.end { - flex: 1 1 auto; -} -.timeline-line .segment.current-end { - height: 2em; - flex: 0 0 auto; - background: linear-gradient(var(--cru-primary-enhance-color), white); -} -.timeline-line .node-container { - flex: 0 0 auto; - position: relative; - width: 18px; - height: 18px; -} -.timeline-line .node { - width: 20px; - height: 20px; - position: absolute; - background: var(--cru-primary-color); - left: -1px; - top: -1px; - border-radius: 50%; - box-sizing: border-box; - z-index: 1; - animation: 1s infinite alternate; - animation-name: timeline-line-node-noncurrent; -} -.timeline-line .node-loading-edge { - color: var(--cru-primary-color); - width: 38px; - height: 38px; - position: absolute; - left: -10px; - top: -10px; - box-sizing: border-box; - z-index: 2; - animation: 1.5s linear infinite timeline-line-node-loading-edge; -} -.timeline-line.current .segment.start { - background: linear-gradient( - var(--cru-primary-color), - var(--cru-primary-enhance-color) - ); -} - -.timeline-line.current .segment.end { - background: var(--cru-primary-enhance-color); -} - -.timeline-line.current .node { - background: var(--cru-primary-enhance-color); - animation-name: timeline-line-node-current; -} - -.timeline-line.loading .node { - background: var(--cru-primary-color); - animation-name: timeline-line-node-loading; -} - -.timeline-item { - position: relative; - padding: 0.5em; -} - -.timeline-item-card { - position: relative; - padding: 0.3em 0.5em 1em 4em; -} - -.timeline-item-card.enter-animation { - animation: 0.6s forwards; - opacity: 0; -} - -@media (max-width: 575.98px) { - .timeline-item-card { - padding-left: 3em; - } -} - -.timeline-item-header { - display: flex; - align-items: center; -} - -.timeline-avatar { - border-radius: 50%; - width: 2em; - height: 2em; -} - -.timeline-item-delete-button { - position: absolute; - right: 0; - bottom: 0; -} - -.timeline-content { - white-space: pre-line; -} - -.timeline-content-image { - max-width: 80%; - max-height: 200px; -} - -.timeline-date-item { - position: relative; - padding: 0.3em 0 0.3em 4em; -} - -.timeline-date-item-badge { - display: inline-block; - padding: 0.1em 0.4em; - border-radius: 0.4em; - background: #7c7c7c; - color: white; - font-size: 0.8em; -} - -.timeline-post-item-options-mask { - background: rgba(255, 255, 255, 0.85); - z-index: 100; - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - - display: flex; - justify-content: space-around; - align-items: center; - - border-radius: var(--cru-card-border-radius); -} - -.timeline-sync-state-badge { - font-size: 0.8em; - padding: 3px 8px; - border-radius: 5px; - background: #e8fbff; -} - -.timeline-sync-state-badge-pin { - display: inline-block; - width: 0.4em; - height: 0.4em; - border-radius: 50%; - vertical-align: middle; - margin-right: 0.6em; -} - -.timeline-card { - position: fixed; - z-index: 1029; - top: 56px; - right: 0; - margin: 0.5em; -} diff --git a/FrontEnd/src/views/timeline/index.tsx b/FrontEnd/src/views/timeline/index.tsx index 131c38c7..cb9fb46f 100644 --- a/FrontEnd/src/views/timeline/index.tsx +++ b/FrontEnd/src/views/timeline/index.tsx @@ -3,8 +3,6 @@ import { useParams } from "react-router-dom"; import { UiLogicError } from "@/common"; -import { useReverseScrollPositionRemember } from "@/utilities/hooks"; - import Timeline from "./Timeline"; const TimelinePage: React.FC = () => { @@ -15,8 +13,6 @@ const TimelinePage: React.FC = () => { const timeline = timelineNameParam || "self"; - useReverseScrollPositionRemember(); - return (
-- cgit v1.2.3