diff options
author | crupest <crupest@outlook.com> | 2023-07-30 23:47:53 +0800 |
---|---|---|
committer | crupest <crupest@outlook.com> | 2023-07-30 23:47:53 +0800 |
commit | 538d6830a0022b49b99695095d85e567b0c86e71 (patch) | |
tree | a0c4d164b05d03f636d603b28f77ca881c16ef10 /FrontEnd/src | |
parent | a148f11c193d35ba489f887ed393aedf58a1c714 (diff) | |
download | timeline-538d6830a0022b49b99695095d85e567b0c86e71.tar.gz timeline-538d6830a0022b49b99695095d85e567b0c86e71.tar.bz2 timeline-538d6830a0022b49b99695095d85e567b0c86e71.zip |
...
Diffstat (limited to 'FrontEnd/src')
31 files changed, 2257 insertions, 24 deletions
diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx index f638f5e8..ca3e4d38 100644 --- a/FrontEnd/src/App.tsx +++ b/FrontEnd/src/App.tsx @@ -1,27 +1,26 @@ -import * as React from "react"; +import { Suspense } from "react"; import { BrowserRouter, Route, Routes } from "react-router-dom"; import AppBar from "./views/common/AppBar"; import NotFoundPage from "./pages/404"; -import LoadingPage from "./views/common/LoadingPage"; +import HomePage from "./pages/home"; import AboutPage from "./pages/about"; import SettingPage from "./pages/setting"; -import Center from "./views/center"; import LoginPage from "./pages/login"; import RegisterPage from "./pages/register"; -import TimelinePage from "./views/timeline"; +import TimelinePage from "./pages/timeline"; +import LoadingPage from "./pages/loading"; import Search from "./views/search"; import Admin from "./views/admin"; import AlertHost from "./views/common/alert/AlertHost"; export default function App() { return ( - <React.Suspense fallback={<LoadingPage />}> + <Suspense fallback={<LoadingPage />}> <BrowserRouter> <AppBar /> <div style={{ height: 56 }} /> <Routes> - <Route path="center" element={<Center />} /> <Route path="login" element={<LoginPage />} /> <Route path="register" element={<RegisterPage />} /> <Route path="settings" element={<SettingPage />} /> @@ -30,10 +29,11 @@ export default function App() { <Route path="admin/*" element={<Admin />} /> <Route path=":owner" element={<TimelinePage />} /> <Route path=":owner/:timeline" element={<TimelinePage />} /> + <Route path="" element={<HomePage />} /> <Route path="*" element={<NotFoundPage />} /> </Routes> <AlertHost /> </BrowserRouter> - </React.Suspense> + </Suspense> ); } diff --git a/FrontEnd/src/pages/home/index.css b/FrontEnd/src/pages/home/index.css new file mode 100644 index 00000000..bc72a182 --- /dev/null +++ b/FrontEnd/src/pages/home/index.css @@ -0,0 +1,7 @@ +.home-page { + width: 100%; + text-align: center; + padding-top: 1em; + font-size: 2em; + color: var(--cru-primary-color); +}
\ No newline at end of file diff --git a/FrontEnd/src/pages/home/index.tsx b/FrontEnd/src/pages/home/index.tsx new file mode 100644 index 00000000..76a3d18c --- /dev/null +++ b/FrontEnd/src/pages/home/index.tsx @@ -0,0 +1,7 @@ +import "./index.css"; + +export default function HomePage() { + return ( + <div className="home-page">Be patient! I'm working on this...</div> + ); +} diff --git a/FrontEnd/src/pages/loading/index.css b/FrontEnd/src/pages/loading/index.css new file mode 100644 index 00000000..08e43c22 --- /dev/null +++ b/FrontEnd/src/pages/loading/index.css @@ -0,0 +1,7 @@ +.loading-page { + width: 100%; + text-align: center; + padding-top: 1em; + font-size: 2em; + color: var(--cru-primary-color); +}
\ No newline at end of file diff --git a/FrontEnd/src/pages/loading/index.tsx b/FrontEnd/src/pages/loading/index.tsx new file mode 100644 index 00000000..e4c8edab --- /dev/null +++ b/FrontEnd/src/pages/loading/index.tsx @@ -0,0 +1,11 @@ +import Spinner from "@/views/common/Spinner"; + +import "./index.css"; + +export default function LoadingPage() { + return ( + <div className="loading-page"> + <Spinner /> + </div> + ); +} diff --git a/FrontEnd/src/pages/timeline/CollapseButton.tsx b/FrontEnd/src/pages/timeline/CollapseButton.tsx new file mode 100644 index 00000000..8270e160 --- /dev/null +++ b/FrontEnd/src/pages/timeline/CollapseButton.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +import IconButton from "@/views/common/button/IconButton"; + +const CollapseButton: React.FC<{ + collapse: boolean; + onClick: () => void; + className?: string; + style?: React.CSSProperties; +}> = ({ collapse, onClick, className, style }) => { + return ( + <IconButton + icon={collapse ? "arrows-angle-expand" : "arrows-angle-contract"} + onClick={onClick} + className={className} + style={style} + /> + ); +}; + +export default CollapseButton; diff --git a/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css new file mode 100644 index 00000000..7fe83b9b --- /dev/null +++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css @@ -0,0 +1,36 @@ +.connection-status-badge {
+ font-size: 0.8em;
+ border-radius: 5px;
+ padding: 0.1em 1em;
+ background-color: #eaf2ff;
+}
+.connection-status-badge::before {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ display: inline-block;
+ content: "";
+ margin-right: 0.6em;
+}
+.connection-status-badge.success {
+ color: #006100;
+}
+.connection-status-badge.success::before {
+ background-color: #006100;
+}
+
+.connection-status-badge.warning {
+ color: #e4a700;
+}
+
+.connection-status-badge.warning::before {
+ background-color: #e4a700;
+}
+
+.connection-status-badge.danger {
+ color: #fd1616;
+}
+
+.connection-status-badge.danger::before {
+ background-color: #fd1616;
+}
diff --git a/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx new file mode 100644 index 00000000..2b820454 --- /dev/null +++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; +import classnames from "classnames"; +import { HubConnectionState } from "@microsoft/signalr"; +import { useTranslation } from "react-i18next"; + +import "./ConnectionStatusBadge.css"; + +export interface ConnectionStatusBadgeProps { + status: HubConnectionState; + className?: string; + style?: React.CSSProperties; +} + +const classNameMap: Record<HubConnectionState, string> = { + Connected: "success", + Connecting: "warning", + Disconnected: "danger", + Disconnecting: "warning", + Reconnecting: "warning", +}; + +const ConnectionStatusBadge: React.FC<ConnectionStatusBadgeProps> = (props) => { + const { status, className, style } = props; + + const { t } = useTranslation(); + + return ( + <div + className={classnames( + "connection-status-badge", + classNameMap[status], + className + )} + style={style} + > + {t(`connectionState.${status}`)} + </div> + ); +}; + +export default ConnectionStatusBadge; diff --git a/FrontEnd/src/pages/timeline/MarkdownPostEdit.css b/FrontEnd/src/pages/timeline/MarkdownPostEdit.css new file mode 100644 index 00000000..e36be992 --- /dev/null +++ b/FrontEnd/src/pages/timeline/MarkdownPostEdit.css @@ -0,0 +1,21 @@ +.timeline-markdown-post-edit-page {
+ overflow: auto;
+ max-height: 300px;
+}
+
+.timeline-markdown-post-edit-image-container {
+ position: relative;
+ text-align: center;
+ margin-bottom: 1em;
+}
+
+.timeline-markdown-post-edit-image {
+ max-width: 100%;
+ max-height: 200px;
+}
+
+.timeline-markdown-post-edit-image-delete-button {
+ position: absolute;
+ right: 10px;
+ top: 2px;
+}
diff --git a/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx new file mode 100644 index 00000000..9c497108 --- /dev/null +++ b/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx @@ -0,0 +1,215 @@ +import * as React from "react"; +import classnames from "classnames"; +import { useTranslation } from "react-i18next"; + +import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; + +import TimelinePostBuilder from "@/services/TimelinePostBuilder"; + +import FlatButton from "@/views/common/button/FlatButton"; +import TabPages from "@/views/common/tab/TabPages"; +import ConfirmDialog from "@/views/common/dialog/ConfirmDialog"; +import Spinner from "@/views/common/Spinner"; +import IconButton from "@/views/common/button/IconButton"; + +import "./MarkdownPostEdit.css"; + +export interface MarkdownPostEditProps { + owner: string; + timeline: string; + onPosted: (post: HttpTimelinePostInfo) => void; + onPostError: () => void; + onClose: () => void; + className?: string; + style?: React.CSSProperties; +} + +const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({ + owner: ownerUsername, + timeline: timelineName, + onPosted, + onClose, + onPostError, + className, + style, +}) => { + const { t } = useTranslation(); + + const [canLeave, setCanLeave] = React.useState<boolean>(true); + + const [process, setProcess] = React.useState<boolean>(false); + + const [showLeaveConfirmDialog, setShowLeaveConfirmDialog] = + React.useState<boolean>(false); + + const [text, _setText] = React.useState<string>(""); + const [images, _setImages] = React.useState<{ file: File; url: string }[]>( + [] + ); + const [previewHtml, _setPreviewHtml] = React.useState<string>(""); + + const _builder = React.useRef<TimelinePostBuilder | null>(null); + + const getBuilder = (): TimelinePostBuilder => { + if (_builder.current == null) { + const builder = new TimelinePostBuilder(() => { + setCanLeave(builder.isEmpty); + _setText(builder.text); + _setImages(builder.images); + _setPreviewHtml(builder.renderHtml()); + }); + _builder.current = builder; + } + return _builder.current; + }; + + const canSend = text.length > 0; + + React.useEffect(() => { + return () => { + getBuilder().dispose(); + }; + }, []); + + React.useEffect(() => { + window.onbeforeunload = (): unknown => { + if (!canLeave) { + return t("timeline.confirmLeave"); + } + }; + + return () => { + window.onbeforeunload = null; + }; + }, [canLeave, t]); + + const send = async (): Promise<void> => { + setProcess(true); + try { + const dataList = await getBuilder().build(); + const post = await getHttpTimelineClient().postPost( + ownerUsername, + timelineName, + { + dataList, + } + ); + onPosted(post); + onClose(); + } catch (e) { + setProcess(false); + onPostError(); + } + }; + + return ( + <> + <TabPages + className={className} + style={style} + pageContainerClassName="py-2" + dense + actions={ + process ? ( + <Spinner /> + ) : ( + <div> + <IconButton + icon="x" + color="danger" + large + className="cru-align-middle me-2" + onClick={() => { + if (canLeave) { + onClose(); + } else { + setShowLeaveConfirmDialog(true); + } + }} + /> + {canSend && ( + <FlatButton text="timeline.send" onClick={() => void send()} /> + )} + </div> + ) + } + pages={[ + { + name: "text", + text: "edit", + page: ( + <textarea + value={text} + disabled={process} + className="cru-fill-parent" + onChange={(event) => { + getBuilder().setMarkdownText(event.currentTarget.value); + }} + /> + ), + }, + { + name: "images", + text: "image", + page: ( + <div className="timeline-markdown-post-edit-page"> + {images.map((image, index) => ( + <div + key={image.url} + className="timeline-markdown-post-edit-image-container" + > + <img + src={image.url} + className="timeline-markdown-post-edit-image" + /> + <IconButton + icon="trash" + color="danger" + className={classnames( + "timeline-markdown-post-edit-image-delete-button", + process && "d-none" + )} + onClick={() => { + getBuilder().deleteImage(index); + }} + /> + </div> + ))} + <input + type="file" + accept="image/jpeg,image/jpg,image/png,image/gif,image/webp" + onChange={(event: React.ChangeEvent<HTMLInputElement>) => { + const { files } = event.currentTarget; + if (files != null && files.length !== 0) { + getBuilder().appendImage(files[0]); + } + }} + disabled={process} + /> + </div> + ), + }, + { + name: "preview", + text: "preview", + page: ( + <div + className="markdown-container timeline-markdown-post-edit-page" + dangerouslySetInnerHTML={{ __html: previewHtml }} + /> + ), + }, + ]} + /> + <ConfirmDialog + onClose={() => setShowLeaveConfirmDialog(false)} + onConfirm={onClose} + open={showLeaveConfirmDialog} + title="timeline.dropDraft" + body="timeline.confirmLeave" + /> + </> + ); +}; + +export default MarkdownPostEdit; diff --git a/FrontEnd/src/pages/timeline/Timeline.css b/FrontEnd/src/pages/timeline/Timeline.css new file mode 100644 index 00000000..4dd4fdcc --- /dev/null +++ b/FrontEnd/src/pages/timeline/Timeline.css @@ -0,0 +1,244 @@ +.timeline { + z-index: 0; + position: relative; + width: 100%; +} + +@keyframes timeline-line-node { + 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; +} +.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.5em 0.5em 0.5em 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; +} + +.timeline-top { + position: sticky; + top: 56px; +} diff --git a/FrontEnd/src/pages/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx new file mode 100644 index 00000000..3a7fbd00 --- /dev/null +++ b/FrontEnd/src/pages/timeline/Timeline.tsx @@ -0,0 +1,207 @@ +import * as React from "react"; +import classnames from "classnames"; +import { useScrollToBottom } from "@/utilities/hooks"; +import { HubConnectionState } from "@microsoft/signalr"; + +import { + HttpForbiddenError, + HttpNetworkError, + HttpNotFoundError, +} from "@/http/common"; +import { + getHttpTimelineClient, + HttpTimelineInfo, + HttpTimelinePostInfo, +} from "@/http/timeline"; + +import { useUser } from "@/services/user"; +import { getTimelinePostUpdate$ } from "@/services/timeline"; + +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 "./Timeline.css"; + +export interface TimelineProps { + className?: string; + style?: React.CSSProperties; + timelineOwner: string; + timelineName: string; +} + +const Timeline: React.FC<TimelineProps> = (props) => { + const { timelineOwner, timelineName, className, style } = props; + + const user = useUser(); + + const [timeline, setTimeline] = React.useState<HttpTimelineInfo | null>(null); + const [posts, setPosts] = React.useState<HttpTimelinePostInfo[] | null>(null); + const [signalrState, setSignalrState] = React.useState<HubConnectionState>( + HubConnectionState.Connecting + ); + const [error, setError] = React.useState< + "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); + + const updateTimeline = (): void => setTimelineReloadKey((o) => o + 1); + const updatePosts = (): void => setPostsReloadKey((o) => o + 1); + + React.useEffect(() => { + setTimeline(null); + setPosts(null); + setError(null); + setSignalrState(HubConnectionState.Connecting); + }, [timelineOwner, timelineName]); + + React.useEffect(() => { + 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"); + } + } + ); + }, [timelineOwner, timelineName, timelineReloadKey]); + + React.useEffect(() => { + getHttpTimelineClient() + .listPost(timelineOwner, timelineName, 1) + .then( + (page) => { + setPosts( + page.items.filter((p): p is HttpTimelinePostInfo => !p.deleted) + ); + setTotalPage(page.totalPageCount); + }, + (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"); + } + } + ); + }, [timelineOwner, timelineName, postsReloadKey]); + + React.useEffect(() => { + const timelinePostUpdate$ = getTimelinePostUpdate$( + timelineOwner, + timelineName + ); + const subscription = timelinePostUpdate$.subscribe(({ update, state }) => { + if (update) { + setPostsReloadKey((o) => o + 1); + } + setSignalrState(state); + }); + return () => { + subscription.unsubscribe(); + }; + }, [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 ( + <div className={className} style={style}> + Offline. + </div> + ); + } else if (error === "notfound") { + return ( + <div className={className} style={style}> + Not exist. + </div> + ); + } else if (error === "forbid") { + return ( + <div className={className} style={style}> + Forbid. + </div> + ); + } else if (error === "error") { + return ( + <div className={className} style={style}> + Error. + </div> + ); + } + return ( + <> + {timeline == null && posts == null && <TimelineLoading />} + {timeline && ( + <TimelineCard + className="timeline-card" + timeline={timeline} + connectionStatus={signalrState} + onReload={updateTimeline} + /> + )} + {posts && ( + <div style={style} className={classnames("timeline", className)}> + <TimelineEmptyItem className="timeline-top" height={50} /> + {timeline?.postable ? ( + <TimelinePostEdit timeline={timeline} onPosted={updatePosts} /> + ) : user == null ? ( + <TimelinePostEditNoLogin /> + ) : null} + <TimelinePostListView posts={posts} onReload={updatePosts} /> + </div> + )} + </> + ); +}; + +export default Timeline; diff --git a/FrontEnd/src/pages/timeline/TimelineCard.tsx b/FrontEnd/src/pages/timeline/TimelineCard.tsx new file mode 100644 index 00000000..8ce133c0 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineCard.tsx @@ -0,0 +1,167 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import classnames from "classnames"; +import { HubConnectionState } from "@microsoft/signalr"; + +import { useIsSmallScreen } from "@/utilities/hooks"; +import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline"; +import { useUser } from "@/services/user"; +import { pushAlert } from "@/services/alert"; +import { HttpTimelineInfo } from "@/http/timeline"; +import { getHttpBookmarkClient } from "@/http/bookmark"; + +import UserAvatar from "@/views/common/user/UserAvatar"; +import PopupMenu from "@/views/common/menu/PopupMenu"; +import FullPageDialog from "@/views/common/dialog/FullPageDialog"; +import Card from "@/views/common/Card"; +import TimelineDeleteDialog from "./TimelineDeleteDialog"; +import ConnectionStatusBadge from "./ConnectionStatusBadge"; +import CollapseButton from "./CollapseButton"; +import { TimelineMemberDialog } from "./TimelineMember"; +import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; +import IconButton from "@/views/common/button/IconButton"; + +export interface TimelinePageCardProps { + timeline: HttpTimelineInfo; + connectionStatus: HubConnectionState; + className?: string; + onReload: () => void; +} + +const TimelineCard: React.FC<TimelinePageCardProps> = (props) => { + const { timeline, connectionStatus, onReload, className } = props; + + const { t } = useTranslation(); + + const [dialog, setDialog] = React.useState< + "member" | "property" | "delete" | null + >(null); + + const [collapse, setCollapse] = React.useState(true); + const toggleCollapse = (): void => { + setCollapse((o) => !o); + }; + + const isSmallScreen = useIsSmallScreen(); + + const user = useUser(); + + const content = ( + <> + <h3 className="cru-color-primary d-inline-block align-middle"> + {timeline.title} + <small className="ms-3 cru-color-secondary">{timeline.nameV2}</small> + </h3> + <div> + <UserAvatar + username={timeline.owner.username} + className="cru-avatar small cru-round me-3" + /> + {timeline.owner.nickname} + <small className="ms-3 cru-color-secondary"> + @{timeline.owner.username} + </small> + </div> + <p className="mb-0">{timeline.description}</p> + <small className="mt-1 d-block"> + {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} + </small> + <div className="mt-2 cru-text-end"> + {user != null ? ( + <IconButton + icon={timeline.isBookmark ? "bookmark-fill" : "bookmark"} + className="me-3" + onClick={() => { + getHttpBookmarkClient() + [timeline.isBookmark ? "delete" : "post"]( + user.username, + timeline.owner.username, + timeline.nameV2, + ) + .then(onReload, () => { + pushAlert({ + message: timeline.isBookmark + ? "timeline.removeBookmarkFail" + : "timeline.addBookmarkFail", + type: "danger", + }); + }); + }} + /> + ) : null} + <IconButton + icon="people" + className="me-3" + onClick={() => setDialog("member")} + /> + {timeline.manageable ? ( + <PopupMenu + items={[ + { + type: "button", + text: "timeline.manageItem.property", + onClick: () => setDialog("property"), + }, + { type: "divider" }, + { + type: "button", + onClick: () => setDialog("delete"), + color: "danger", + text: "timeline.manageItem.delete", + }, + ]} + containerClassName="d-inline" + > + <IconButton icon="three-dots-vertical" /> + </PopupMenu> + ) : null} + </div> + </> + ); + + return ( + <> + <Card className={classnames("p-2 cru-clearfix", className)}> + <div + className={classnames( + "cru-float-right d-flex align-items-center", + !collapse && "ms-3", + )} + > + <ConnectionStatusBadge status={connectionStatus} className="me-2" /> + <CollapseButton collapse={collapse} onClick={toggleCollapse} /> + </div> + {isSmallScreen ? ( + <FullPageDialog + onBack={toggleCollapse} + show={!collapse} + contentContainerClassName="p-2" + > + {content} + </FullPageDialog> + ) : ( + <div style={{ display: collapse ? "none" : "inline" }}>{content}</div> + )} + </Card> + <TimelineMemberDialog + timeline={timeline} + onClose={() => setDialog(null)} + open={dialog === "member"} + onChange={onReload} + /> + <TimelinePropertyChangeDialog + timeline={timeline} + close={() => setDialog(null)} + open={dialog === "property"} + onChange={onReload} + /> + <TimelineDeleteDialog + timeline={timeline} + open={dialog === "delete"} + close={() => setDialog(null)} + /> + </> + ); +}; + +export default TimelineCard; diff --git a/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx new file mode 100644 index 00000000..5f4ac706 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import TimelineLine from "./TimelineLine"; + +export interface TimelineDateItemProps { + date: Date; +} + +const TimelineDateLabel: React.FC<TimelineDateItemProps> = ({ date }) => { + return ( + <div className="timeline-date-item"> + <TimelineLine center="none" /> + <div className="timeline-date-item-badge"> + {date.toLocaleDateString()} + </div> + </div> + ); +}; + +export default TimelineDateLabel; diff --git a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx new file mode 100644 index 00000000..d5b22aee --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; +import { useNavigate } from "react-router-dom"; +import { Trans } from "react-i18next"; + +import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; + +import OperationDialog from "@/views/common/dialog/OperationDialog"; + +interface TimelineDeleteDialog { + timeline: HttpTimelineInfo; + open: boolean; + close: () => void; +} + +const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => { + const navigate = useNavigate(); + + const { timeline } = props; + + return ( + <OperationDialog + open={props.open} + onClose={props.close} + title="timeline.deleteDialog.title" + color="danger" + inputPrompt={() => ( + <Trans + i18nKey="timeline.deleteDialog.inputPrompt" + values={{ name: timeline.nameV2 }} + > + 0<code className="mx-2">1</code>2 + </Trans> + )} + inputs={{ + inputs: [ + { + key: "name", + type: "text", + label: "", + }, + ], + validator: ({ name }) => { + if (name !== timeline.nameV2) { + return { name: "timeline.deleteDialog.notMatch" }; + } + }, + }} + onProcess={() => { + return getHttpTimelineClient().deleteTimeline( + timeline.owner.username, + timeline.nameV2, + ); + }} + onSuccessAndClose={() => { + navigate("/", { replace: true }); + }} + /> + ); +}; + +export default TimelineDeleteDialog; diff --git a/FrontEnd/src/pages/timeline/TimelineEmptyItem.tsx b/FrontEnd/src/pages/timeline/TimelineEmptyItem.tsx new file mode 100644 index 00000000..5e0728d4 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineEmptyItem.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import classnames from "classnames"; + +import TimelineLine, { TimelineLineProps } from "./TimelineLine"; + +export interface TimelineEmptyItemProps extends Partial<TimelineLineProps> { + height?: number | string; + className?: string; + style?: React.CSSProperties; +} + +const TimelineEmptyItem: React.FC<TimelineEmptyItemProps> = (props) => { + const { height, style, className, center, ...lineProps } = props; + + return ( + <div + style={{ ...style, height: height }} + className={classnames("timeline-item", className)} + > + <TimelineLine center={center ?? "none"} {...lineProps} /> + </div> + ); +}; + +export default TimelineEmptyItem; diff --git a/FrontEnd/src/pages/timeline/TimelineLine.tsx b/FrontEnd/src/pages/timeline/TimelineLine.tsx new file mode 100644 index 00000000..4a87e6e0 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineLine.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import classnames from "classnames"; + +export interface TimelineLineProps { + current?: boolean; + startSegmentLength?: string | number; + center: "node" | "loading" | "none"; + className?: string; + style?: React.CSSProperties; +} + +const TimelineLine: React.FC<TimelineLineProps> = ({ + startSegmentLength, + center, + current, + className, + style, +}) => { + return ( + <div + className={classnames( + "timeline-line", + current && "current", + center === "loading" && "loading", + className + )} + style={style} + > + <div className="segment start" style={{ height: startSegmentLength }} /> + {center !== "none" ? ( + <div className="node-container"> + <div className="node"></div> + {center === "loading" ? ( + <svg className="node-loading-edge" viewBox="0 0 100 100"> + <path + d="M 50,10 A 40 40 45 0 1 78.28,21.72" + stroke="currentcolor" + strokeLinecap="square" + strokeWidth="8" + /> + </svg> + ) : null} + </div> + ) : null} + {center !== "loading" ? <div className="segment end"></div> : null} + {current && <div className="segment current-end" />} + </div> + ); +}; + +export default TimelineLine; diff --git a/FrontEnd/src/pages/timeline/TimelineLoading.tsx b/FrontEnd/src/pages/timeline/TimelineLoading.tsx new file mode 100644 index 00000000..f876cba9 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineLoading.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; + +import TimelineEmptyItem from "./TimelineEmptyItem"; + +const TimelineLoading: React.FC = () => { + return ( + <TimelineEmptyItem + className="timeline-top-loading-enter" + height={100} + center="loading" + startSegmentLength={56} + /> + ); +}; + +export default TimelineLoading; diff --git a/FrontEnd/src/pages/timeline/TimelineMember.css b/FrontEnd/src/pages/timeline/TimelineMember.css new file mode 100644 index 00000000..adb78764 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineMember.css @@ -0,0 +1,8 @@ +.timeline-member-item {
+ border: var(--cru-background-1-color) solid;
+ border-width: 0.5px 1px;
+}
+
+.timeline-member-item > div {
+ padding: 0.5em;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineMember.tsx b/FrontEnd/src/pages/timeline/TimelineMember.tsx new file mode 100644 index 00000000..6c5d29ba --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineMember.tsx @@ -0,0 +1,202 @@ +import { useState } from "react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; + +import { convertI18nText, I18nText } from "@/common"; + +import { HttpUser } from "@/http/user"; +import { getHttpSearchClient } from "@/http/search"; +import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; + +import SearchInput from "@/views/common/SearchInput"; +import UserAvatar from "@/views/common/user/UserAvatar"; +import Button from "@/views/common/button/Button"; +import Dialog from "@/views/common/dialog/Dialog"; + +import "./TimelineMember.css"; + +const TimelineMemberItem: React.FC<{ + user: HttpUser; + add?: boolean; + onAction?: (username: string) => void; +}> = ({ user, add, onAction }) => { + return ( + <div className="container timeline-member-item"> + <div className="row"> + <div className="col col-auto"> + <UserAvatar username={user.username} className="cru-avatar small" /> + </div> + <div className="col"> + <div className="row">{user.nickname}</div> + <small className="row">{"@" + user.username}</small> + </div> + {onAction ? ( + <div className="col col-auto"> + <Button + text={`timeline.member.${add ? "add" : "remove"}`} + color={add ? "success" : "danger"} + onClick={() => { + onAction(user.username); + }} + /> + </div> + ) : null} + </div> + </div> + ); +}; + +const TimelineMemberUserSearch: React.FC<{ + timeline: HttpTimelineInfo; + onChange: () => void; +}> = ({ timeline, onChange }) => { + const { t } = useTranslation(); + + const [userSearchText, setUserSearchText] = useState<string>(""); + const [userSearchState, setUserSearchState] = useState< + | { + type: "users"; + data: HttpUser[]; + } + | { type: "error"; data: I18nText } + | { type: "loading" } + | { type: "init" } + >({ type: "init" }); + + return ( + <> + <SearchInput + className="mt-3" + value={userSearchText} + onChange={(v) => { + setUserSearchText(v); + }} + loading={userSearchState.type === "loading"} + onButtonClick={() => { + if (userSearchText === "") { + setUserSearchState({ + type: "error", + data: "login.emptyUsername", + }); + return; + } + setUserSearchState({ type: "loading" }); + getHttpSearchClient() + .searchUsers(userSearchText) + .then( + (users) => { + users = users.filter( + (user) => + timeline.members.findIndex( + (m) => m.username === user.username + ) === -1 && timeline.owner.username !== user.username + ); + setUserSearchState({ type: "users", data: users }); + }, + (e) => { + setUserSearchState({ + type: "error", + data: { type: "custom", value: String(e) }, + }); + } + ); + }} + /> + {(() => { + if (userSearchState.type === "users") { + const users = userSearchState.data; + if (users.length === 0) { + return <div>{t("timeline.member.noUserAvailableToAdd")}</div>; + } else { + return ( + <div className="mt-2"> + {users.map((user) => ( + <TimelineMemberItem + key={user.username} + user={user} + add + onAction={() => { + void getHttpTimelineClient() + .memberPut( + timeline.owner.username, + timeline.nameV2, + user.username + ) + .then(() => { + setUserSearchText(""); + setUserSearchState({ type: "init" }); + onChange(); + }); + }} + /> + ))} + </div> + ); + } + } else if (userSearchState.type === "error") { + return ( + <div className="cru-color-danger"> + {convertI18nText(userSearchState.data, t)} + </div> + ); + } + })()} + </> + ); +}; + +export interface TimelineMemberProps { + timeline: HttpTimelineInfo; + onChange: () => void; +} + +const TimelineMember: React.FC<TimelineMemberProps> = (props) => { + const { timeline, onChange } = props; + const members = [timeline.owner, ...timeline.members]; + + return ( + <div className="container px-4 py-3"> + <div> + {members.map((member, index) => ( + <TimelineMemberItem + key={member.username} + user={member} + onAction={ + timeline.manageable && index !== 0 + ? () => { + void getHttpTimelineClient() + .memberDelete( + timeline.owner.username, + timeline.nameV2, + member.username + ) + .then(onChange); + } + : undefined + } + /> + ))} + </div> + {timeline.manageable ? ( + <TimelineMemberUserSearch timeline={timeline} onChange={onChange} /> + ) : null} + </div> + ); +}; + +export default TimelineMember; + +export interface TimelineMemberDialogProps extends TimelineMemberProps { + open: boolean; + onClose: () => void; +} + +export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = ( + props +) => { + return ( + <Dialog open={props.open} onClose={props.onClose}> + <TimelineMember {...props} /> + </Dialog> + ); +}; diff --git a/FrontEnd/src/pages/timeline/TimelinePostContentView.tsx b/FrontEnd/src/pages/timeline/TimelinePostContentView.tsx new file mode 100644 index 00000000..41080e10 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostContentView.tsx @@ -0,0 +1,187 @@ +import * as React from "react"; +import classnames from "classnames"; +import { marked } from "marked"; + +import { UiLogicError } from "@/common"; + +import { HttpNetworkError } from "@/http/common"; +import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; + +import { useUser } from "@/services/user"; + +import Skeleton from "@/views/common/Skeleton"; +import LoadFailReload from "@/views/common/LoadFailReload"; + +const TextView: React.FC<TimelinePostContentViewProps> = (props) => { + const { post, className, style } = props; + + const [text, setText] = React.useState<string | null>(null); + const [error, setError] = React.useState<"offline" | "error" | null>(null); + + const [reloadKey, setReloadKey] = React.useState<number>(0); + + React.useEffect(() => { + let subscribe = true; + + setText(null); + setError(null); + + void getHttpTimelineClient() + .getPostDataAsString(post.timelineOwnerV2, post.timelineNameV2, post.id) + .then( + (data) => { + if (subscribe) setText(data); + }, + (error) => { + if (subscribe) { + if (error instanceof HttpNetworkError) { + setError("offline"); + } else { + setError("error"); + } + } + } + ); + + return () => { + subscribe = false; + }; + }, [post.timelineOwnerV2, post.timelineNameV2, post.id, reloadKey]); + + if (error != null) { + return ( + <LoadFailReload + className={className} + style={style} + onReload={() => setReloadKey(reloadKey + 1)} + /> + ); + } else if (text == null) { + return <Skeleton />; + } else { + return ( + <div className={className} style={style}> + {text} + </div> + ); + } +}; + +const ImageView: React.FC<TimelinePostContentViewProps> = (props) => { + const { post, className, style } = props; + + useUser(); + + return ( + <img + src={getHttpTimelineClient().generatePostDataUrl( + post.timelineOwnerV2, + post.timelineNameV2, + post.id + )} + className={classnames(className, "timeline-content-image")} + style={style} + /> + ); +}; + +const MarkdownView: React.FC<TimelinePostContentViewProps> = (props) => { + const { post, className, style } = props; + + const [markdown, setMarkdown] = React.useState<string | null>(null); + const [error, setError] = React.useState<"offline" | "error" | null>(null); + + const [reloadKey, setReloadKey] = React.useState<number>(0); + + React.useEffect(() => { + let subscribe = true; + + setMarkdown(null); + setError(null); + + void getHttpTimelineClient() + .getPostDataAsString(post.timelineOwnerV2, post.timelineNameV2, post.id) + .then( + (data) => { + if (subscribe) setMarkdown(data); + }, + (error) => { + if (subscribe) { + if (error instanceof HttpNetworkError) { + setError("offline"); + } else { + setError("error"); + } + } + } + ); + + return () => { + subscribe = false; + }; + }, [post.timelineOwnerV2, post.timelineNameV2, post.id, reloadKey]); + + const markdownHtml = React.useMemo<string | null>(() => { + if (markdown == null) return null; + return marked.parse(markdown); + }, [markdown]); + + if (error != null) { + return ( + <LoadFailReload + className={className} + style={style} + onReload={() => setReloadKey(reloadKey + 1)} + /> + ); + } else if (markdown == null) { + return <Skeleton />; + } else { + if (markdownHtml == null) { + throw new UiLogicError("Markdown is not null but markdown html is."); + } + return ( + <div + className={classnames(className, "markdown-container")} + style={style} + dangerouslySetInnerHTML={{ + __html: markdownHtml, + }} + /> + ); + } +}; + +export interface TimelinePostContentViewProps { + post: HttpTimelinePostInfo; + className?: string; + style?: React.CSSProperties; +} + +const viewMap: Record<string, React.FC<TimelinePostContentViewProps>> = { + "text/plain": TextView, + "text/markdown": MarkdownView, + "image/png": ImageView, + "image/jpeg": ImageView, + "image/gif": ImageView, + "image/webp": ImageView, +}; + +const TimelinePostContentView: React.FC<TimelinePostContentViewProps> = ( + props +) => { + const { post, className, style } = props; + + const type = post.dataList[0].kind; + + if (type in viewMap) { + const View = viewMap[type]; + return <View post={post} className={className} style={style} />; + } else { + // TODO: i18n + console.error("Unknown post type", post); + return <div>Error, unknown post type!</div>; + } +}; + +export default TimelinePostContentView; diff --git a/FrontEnd/src/pages/timeline/TimelinePostEdit.css b/FrontEnd/src/pages/timeline/TimelinePostEdit.css new file mode 100644 index 00000000..9b7629e2 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostEdit.css @@ -0,0 +1,10 @@ +.timeline-post-edit {
+ position: sticky !important;
+ top: 106px;
+ z-index: 100;
+}
+
+.timeline-post-edit-image {
+ max-width: 100px;
+ max-height: 100px;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostEdit.tsx b/FrontEnd/src/pages/timeline/TimelinePostEdit.tsx new file mode 100644 index 00000000..c1fa0dd9 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostEdit.tsx @@ -0,0 +1,267 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; + +import { UiLogicError } from "@/common"; + +import { + getHttpTimelineClient, + HttpTimelineInfo, + HttpTimelinePostInfo, + HttpTimelinePostPostRequestData, +} from "@/http/timeline"; + +import { pushAlert } from "@/services/alert"; + +import base64 from "@/utilities/base64"; + +import BlobImage from "@/views/common/BlobImage"; +import LoadingButton from "@/views/common/button/LoadingButton"; +import PopupMenu from "@/views/common/menu/PopupMenu"; +import MarkdownPostEdit from "./MarkdownPostEdit"; +import TimelinePostEditCard from "./TimelinePostEditCard"; +import IconButton from "@/views/common/button/IconButton"; + +import "./TimelinePostEdit.css"; + +interface TimelinePostEditTextProps { + text: string; + disabled: boolean; + onChange: (text: string) => void; + className?: string; + style?: React.CSSProperties; +} + +const TimelinePostEditText: React.FC<TimelinePostEditTextProps> = (props) => { + const { text, disabled, onChange, className, style } = props; + + return ( + <textarea + value={text} + disabled={disabled} + onChange={(event) => { + onChange(event.target.value); + }} + className={className} + style={style} + /> + ); +}; + +interface TimelinePostEditImageProps { + onSelect: (file: File | null) => void; + disabled: boolean; +} + +const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => { + const { onSelect, disabled } = props; + + const { t } = useTranslation(); + + const [file, setFile] = React.useState<File | null>(null); + const [error, setError] = React.useState<boolean>(false); + + const onInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { + setError(false); + const files = e.target.files; + if (files == null || files.length === 0) { + setFile(null); + onSelect(null); + } else { + setFile(files[0]); + } + }; + + React.useEffect(() => { + return () => { + onSelect(null); + }; + }, [onSelect]); + + return ( + <> + <input + type="file" + onChange={onInputChange} + accept="image/*" + disabled={disabled} + className="mx-3 my-1" + /> + {file != null && !error && ( + <BlobImage + blob={file} + className="timeline-post-edit-image" + onLoad={() => onSelect(file)} + onError={() => { + onSelect(null); + setError(true); + }} + /> + )} + {error ? <div className="text-danger">{t("loadImageError")}</div> : null} + </> + ); +}; + +type PostKind = "text" | "markdown" | "image"; + +const postKindIconMap: Record<PostKind, string> = { + text: "fonts", + markdown: "markdown", + image: "image", +}; + +export interface TimelinePostEditProps { + className?: string; + style?: React.CSSProperties; + timeline: HttpTimelineInfo; + onPosted: (newPost: HttpTimelinePostInfo) => void; +} + +const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { + const { timeline, style, className, onPosted } = props; + + const { t } = useTranslation(); + + const [process, setProcess] = React.useState<boolean>(false); + + const [kind, setKind] = React.useState<Exclude<PostKind, "markdown">>("text"); + const [showMarkdown, setShowMarkdown] = React.useState<boolean>(false); + + const [text, setText] = React.useState<string>(""); + const [image, setImage] = React.useState<File | null>(null); + + const draftTextLocalStorageKey = `timeline.${timeline.owner.username}.${timeline.nameV2}.postDraft.text`; + + React.useEffect(() => { + setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? ""); + }, [draftTextLocalStorageKey]); + + const canSend = + (kind === "text" && text.length !== 0) || + (kind === "image" && image != null); + + const onPostError = (): void => { + pushAlert({ + type: "danger", + message: "timeline.sendPostFailed", + }); + }; + + const onSend = async (): Promise<void> => { + setProcess(true); + + let requestData: HttpTimelinePostPostRequestData; + switch (kind) { + case "text": + requestData = { + contentType: "text/plain", + data: await base64(text), + }; + break; + case "image": + if (image == null) { + throw new UiLogicError( + "Content type is image but image blob is null.", + ); + } + requestData = { + contentType: image.type, + data: await base64(image), + }; + break; + default: + throw new UiLogicError("Unknown content type."); + } + + getHttpTimelineClient() + .postPost(timeline.owner.username, timeline.nameV2, { + dataList: [requestData], + }) + .then( + (data) => { + if (kind === "text") { + setText(""); + window.localStorage.removeItem(draftTextLocalStorageKey); + } + setProcess(false); + setKind("text"); + onPosted(data); + }, + () => { + setProcess(false); + onPostError(); + }, + ); + }; + + return ( + <TimelinePostEditCard className={className} style={style}> + {showMarkdown ? ( + <MarkdownPostEdit + className="cru-fill-parent" + onClose={() => setShowMarkdown(false)} + owner={timeline.owner.username} + timeline={timeline.nameV2} + onPosted={onPosted} + onPostError={onPostError} + /> + ) : ( + <div className="row"> + <div className="col px-1 py-1"> + {(() => { + if (kind === "text") { + return ( + <TimelinePostEditText + className="cru-fill-parent timeline-post-edit" + text={text} + disabled={process} + onChange={(t) => { + setText(t); + window.localStorage.setItem(draftTextLocalStorageKey, t); + }} + /> + ); + } else if (kind === "image") { + return ( + <TimelinePostEditImage + onSelect={setImage} + disabled={process} + /> + ); + } + })()} + </div> + <div className="col col-auto align-self-end m-1"> + <div className="d-block cru-text-center mt-1 mb-2"> + <PopupMenu + items={(["text", "image", "markdown"] as const).map((kind) => ({ + type: "button", + text: `timeline.post.type.${kind}`, + iconClassName: postKindIconMap[kind], + onClick: () => { + if (kind === "markdown") { + setShowMarkdown(true); + } else { + setKind(kind); + } + }, + }))} + > + <IconButton large icon={postKindIconMap[kind]} /> + </PopupMenu> + </div> + <LoadingButton + onClick={() => void onSend()} + disabled={!canSend} + loading={process} + > + {t("timeline.send")} + </LoadingButton> + </div> + </div> + )} + </TimelinePostEditCard> + ); +}; + +export default TimelinePostEdit; diff --git a/FrontEnd/src/pages/timeline/TimelinePostEditCard.tsx b/FrontEnd/src/pages/timeline/TimelinePostEditCard.tsx new file mode 100644 index 00000000..fdd53983 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostEditCard.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; +import classnames from "classnames"; + +import Card from "@/views/common/Card"; +import TimelineLine from "./TimelineLine"; + +import "./TimelinePostEdit.css"; + +export interface TimelinePostEditCardProps { + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +const TimelinePostEdit: React.FC<TimelinePostEditCardProps> = ({ + className, + style, + children, +}) => { + return ( + <div + className={classnames("timeline-item timeline-post-edit", className)} + style={style} + > + <TimelineLine center="node" /> + <Card className="timeline-item-card">{children}</Card> + </div> + ); +}; + +export default TimelinePostEdit; diff --git a/FrontEnd/src/pages/timeline/TimelinePostEditNoLogin.tsx b/FrontEnd/src/pages/timeline/TimelinePostEditNoLogin.tsx new file mode 100644 index 00000000..1ef0a287 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostEditNoLogin.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import { Trans } from "react-i18next"; +import { Link } from "react-router-dom"; + +import TimelinePostEditCard from "./TimelinePostEditCard"; + +export default function TimelinePostEditNoLogin(): React.ReactElement | null { + return ( + <TimelinePostEditCard> + <div className="mt-3 mb-4"> + <Trans + i18nKey="timeline.postNoLogin" + components={{ l: <Link to="/login" /> }} + /> + </div> + </TimelinePostEditCard> + ); +} diff --git a/FrontEnd/src/pages/timeline/TimelinePostListView.tsx b/FrontEnd/src/pages/timeline/TimelinePostListView.tsx new file mode 100644 index 00000000..f878b004 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostListView.tsx @@ -0,0 +1,76 @@ +import { Fragment } from "react"; +import * as React from "react"; + +import { HttpTimelinePostInfo } from "@/http/timeline"; + +import TimelinePostView from "./TimelinePostView"; +import TimelineDateLabel from "./TimelineDateLabel"; + +function dateEqual(left: Date, right: Date): boolean { + return ( + left.getDate() == right.getDate() && + left.getMonth() == right.getMonth() && + left.getFullYear() == right.getFullYear() + ); +} + +export interface TimelinePostListViewProps { + posts: HttpTimelinePostInfo[]; + onReload: () => void; +} + +const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => { + const { posts, onReload } = props; + + const groupedPosts = React.useMemo< + { + date: Date; + posts: (HttpTimelinePostInfo & { index: number })[]; + }[] + >(() => { + const result: { + date: Date; + posts: (HttpTimelinePostInfo & { index: number })[]; + }[] = []; + let index = 0; + for (const post of posts) { + const time = new Date(post.time); + if (result.length === 0) { + result.push({ date: time, posts: [{ ...post, index }] }); + } else { + const lastGroup = result[result.length - 1]; + if (dateEqual(lastGroup.date, time)) { + lastGroup.posts.push({ ...post, index }); + } else { + result.push({ date: time, posts: [{ ...post, index }] }); + } + } + index++; + } + return result; + }, [posts]); + + return ( + <> + {groupedPosts.map((group) => { + return ( + <Fragment key={group.date.toDateString()}> + <TimelineDateLabel date={group.date} /> + {group.posts.map((post) => { + return ( + <TimelinePostView + key={post.id} + post={post} + onChanged={onReload} + onDeleted={onReload} + /> + ); + })} + </Fragment> + ); + })} + </> + ); +}; + +export default TimelinePostListView; diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.tsx b/FrontEnd/src/pages/timeline/TimelinePostView.tsx new file mode 100644 index 00000000..f7aec169 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostView.tsx @@ -0,0 +1,149 @@ +import * as React from "react"; +import classnames from "classnames"; + +import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; + +import { pushAlert } from "@/services/alert"; + +import { useClickOutside } from "@/utilities/hooks"; + +import UserAvatar from "@/views/common/user/UserAvatar"; +import Card from "@/views/common/Card"; +import FlatButton from "@/views/common/button/FlatButton"; +import ConfirmDialog from "@/views/common/dialog/ConfirmDialog"; +import TimelineLine from "./TimelineLine"; +import TimelinePostContentView from "./TimelinePostContentView"; +import IconButton from "@/views/common/button/IconButton"; + +export interface TimelinePostViewProps { + post: HttpTimelinePostInfo; + className?: string; + style?: React.CSSProperties; + cardStyle?: React.CSSProperties; + onChanged: (post: HttpTimelinePostInfo) => void; + onDeleted: () => void; +} + +const TimelinePostView: React.FC<TimelinePostViewProps> = (props) => { + const { post, className, style, cardStyle, onChanged, onDeleted } = props; + + const [operationMaskVisible, setOperationMaskVisible] = + React.useState<boolean>(false); + const [dialog, setDialog] = React.useState< + "delete" | "changeproperty" | null + >(null); + + const [maskElement, setMaskElement] = React.useState<HTMLElement | null>( + null, + ); + + useClickOutside(maskElement, () => setOperationMaskVisible(false)); + + const cardRef = React.useRef<HTMLDivElement>(null); + React.useEffect(() => { + const cardIntersectionObserver = new IntersectionObserver(([e]) => { + if (e.intersectionRatio > 0) { + if (cardRef.current != null) { + cardRef.current.style.animationName = "timeline-post-enter"; + } + } + }); + if (cardRef.current) { + cardIntersectionObserver.observe(cardRef.current); + } + + return () => { + cardIntersectionObserver.disconnect(); + }; + }, []); + + return ( + <div + id={`timeline-post-${post.id}`} + className={classnames("timeline-item", className)} + style={style} + > + <TimelineLine center="node" /> + <Card + containerRef={cardRef} + className="timeline-item-card enter-animation" + style={cardStyle} + > + {post.editable ? ( + <IconButton + icon="chevron-down" + color="primary" + className="cru-float-right" + onClick={(e) => { + setOperationMaskVisible(true); + e.stopPropagation(); + }} + /> + ) : null} + <div className="timeline-item-header"> + <span className="me-2"> + <span> + <UserAvatar + username={post.author.username} + className="timeline-avatar me-1" + /> + <small className="text-dark me-2">{post.author.nickname}</small> + <small className="text-secondary white-space-no-wrap"> + {new Date(post.time).toLocaleTimeString()} + </small> + </span> + </span> + </div> + <div className="timeline-content"> + <TimelinePostContentView post={post} /> + </div> + {operationMaskVisible ? ( + <div + ref={setMaskElement} + className="timeline-post-item-options-mask" + onClick={() => { + setOperationMaskVisible(false); + }} + > + <FlatButton + text="changeProperty" + onClick={(e) => { + setDialog("changeproperty"); + e.stopPropagation(); + }} + /> + <FlatButton + text="delete" + color="danger" + onClick={(e) => { + setDialog("delete"); + e.stopPropagation(); + }} + /> + </div> + ) : null} + </Card> + <ConfirmDialog + title="timeline.post.deleteDialog.title" + body="timeline.post.deleteDialog.prompt" + open={dialog === "delete"} + onClose={() => { + setDialog(null); + setOperationMaskVisible(false); + }} + onConfirm={() => { + void getHttpTimelineClient() + .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id) + .then(onDeleted, () => { + pushAlert({ + type: "danger", + message: "timeline.deletePostFailed", + }); + }); + }} + /> + </div> + ); +}; + +export default TimelinePostView; diff --git a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx new file mode 100644 index 00000000..e26df3eb --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx @@ -0,0 +1,87 @@ +import * as React from "react"; + +import { + getHttpTimelineClient, + HttpTimelineInfo, + HttpTimelinePatchRequest, + kTimelineVisibilities, + TimelineVisibility, +} from "@/http/timeline"; + +import OperationDialog from "@/views/common/dialog/OperationDialog"; + +export interface TimelinePropertyChangeDialogProps { + open: boolean; + close: () => void; + timeline: HttpTimelineInfo; + onChange: () => void; +} + +const labelMap: { [key in TimelineVisibility]: string } = { + Private: "timeline.visibility.private", + Public: "timeline.visibility.public", + Register: "timeline.visibility.register", +}; + +const TimelinePropertyChangeDialog: React.FC< + TimelinePropertyChangeDialogProps +> = (props) => { + const { timeline, onChange } = props; + + return ( + <OperationDialog + title={"timeline.dialogChangeProperty.title"} + inputs={{ + scheme: { + inputs: [ + { + key: "title", + type: "text", + label: "timeline.dialogChangeProperty.titleField", + }, + { + key: "visibility", + type: "select", + label: "timeline.dialogChangeProperty.visibility", + options: kTimelineVisibilities.map((v) => ({ + label: labelMap[v], + value: v, + })), + }, + { + key: "description", + type: "text", + label: "timeline.dialogChangeProperty.description", + }, + ], + }, + dataInit: { + values: { + title: timeline.title, + visibility: timeline.visibility, + description: timeline.description, + }, + }, + }} + open={props.open} + onClose={props.close} + onProcess={({ title, visibility, description }) => { + const req: HttpTimelinePatchRequest = {}; + if (title !== timeline.title) { + req.title = title as string; + } + if (visibility !== timeline.visibility) { + req.visibility = visibility as TimelineVisibility; + } + if (description !== timeline.description) { + req.description = description as string; + } + return getHttpTimelineClient() + .patchTimeline(timeline.owner.username, timeline.nameV2, req) + .then(onChange); + }} + /> + ); +}; + +export default TimelinePropertyChangeDialog; diff --git a/FrontEnd/src/pages/timeline/index.tsx b/FrontEnd/src/pages/timeline/index.tsx new file mode 100644 index 00000000..1dffdcc1 --- /dev/null +++ b/FrontEnd/src/pages/timeline/index.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { useParams } from "react-router-dom"; + +import { UiLogicError } from "@/common"; + +import Timeline from "./Timeline"; + +const TimelinePage: React.FC = () => { + const { owner, timeline: timelineNameParam } = useParams(); + + if (owner == null || owner == "") + throw new UiLogicError("Route param owner is not set."); + + const timeline = timelineNameParam || "self"; + + return ( + <div className="container"> + <Timeline timelineOwner={owner} timelineName={timeline} /> + </div> + ); +}; + +export default TimelinePage; diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.tsx b/FrontEnd/src/views/common/dialog/OperationDialog.tsx index da1ff0e0..de92e541 100644 --- a/FrontEnd/src/views/common/dialog/OperationDialog.tsx +++ b/FrontEnd/src/views/common/dialog/OperationDialog.tsx @@ -43,7 +43,7 @@ export interface OperationDialogProps<TData> { color?: ThemeColor; inputColor?: ThemeColor; title: Text; - inputPrompt?: Text; + inputPrompt?: () => ReactNode; successPrompt?: (data: TData) => ReactNode; failurePrompt?: (error: unknown) => ReactNode; @@ -68,8 +68,6 @@ function OperationDialog<TData>(props: OperationDialogProps<TData>) { onSuccessAndClose, } = props; - const c = useC(); - type Step = | { type: "input" } | { type: "process" } @@ -130,7 +128,7 @@ function OperationDialog<TData>(props: OperationDialogProps<TData>) { body = ( <div> - <OperationDialogPrompt customMessage={c(inputPrompt)} /> + <OperationDialogPrompt customMessage={inputPrompt?.()} /> <InputGroup containerClassName="cru-operation-dialog-input-group" color={inputColor ?? "primary"} diff --git a/FrontEnd/src/views/common/input/InputGroup.tsx b/FrontEnd/src/views/common/input/InputGroup.tsx index ee89b05c..5714411c 100644 --- a/FrontEnd/src/views/common/input/InputGroup.tsx +++ b/FrontEnd/src/views/common/input/InputGroup.tsx @@ -70,6 +70,13 @@ export type InputErrorDict = Record<string, Text>; export type InputDisabledDict = Record<string, boolean>; export type InputDirtyDict = Record<string, boolean>; +export type GeneralInputErrorDict = + | { + [key: string]: Text | null | undefined; + } + | null + | undefined; + type MakeInputInfo<I extends Input> = Omit<I, "value" | "error" | "disabled">; export type InputInfo = { @@ -79,7 +86,7 @@ export type InputInfo = { export type Validator = ( values: InputValueDict, inputs: InputInfo[], -) => InputErrorDict; +) => GeneralInputErrorDict; export type InputScheme = { inputs: InputInfo[]; @@ -98,7 +105,12 @@ export type State = { data: InputData; }; -export type DataInitialization = Partial<InputData>; +export type DataInitialization = { + values?: InputValueDict; + errors?: GeneralInputErrorDict; + disabled?: InputDisabledDict; + dirties?: InputDirtyDict; +}; export type Initialization = { scheme: InputScheme; @@ -118,14 +130,14 @@ export interface InputGroupProps { onChange: (index: number, value: Input["value"]) => void; } -function cleanObject<V>(o: Record<string, V>): Record<string, V> { +function cleanObject<V>(o: Record<string, V>): Record<string, NonNullable<V>> { const result = { ...o }; for (const key of Object.keys(result)) { if (result[key] == null) { delete result[key]; } } - return result; + return result as never; } export type ConfirmResult = @@ -138,6 +150,14 @@ export type ConfirmResult = errors: InputErrorDict; }; +function validate( + validator: Validator | null | undefined, + values: InputValueDict, + inputs: InputInfo[], +): InputErrorDict { + return cleanObject(validator?.(values, inputs) ?? {}); +} + export function useInputs(options: { init: Initializer }): { inputGroupProps: InputGroupProps; hasError: boolean; @@ -182,18 +202,22 @@ export function useInputs(options: { init: Initializer }): { }; checkKeys(dataInit?.values); - checkKeys(dataInit?.errors); + checkKeys(dataInit?.errors ?? {}); checkKeys(dataInit?.disabled); checkKeys(dataInit?.dirties); } - function clean<V>(dict: Record<string, V> | undefined): Record<string, V> { + function clean<V>( + dict: Record<string, V> | null | undefined, + ): Record<string, NonNullable<V>> { return dict != null ? cleanObject(dict) : {}; } const values: InputValueDict = {}; const disabled: InputDisabledDict = clean(dataInit?.disabled); const dirties: InputDirtyDict = clean(dataInit?.dirties); + const isErrorSet = dataInit?.errors != null; + let errors: InputErrorDict = clean(dataInit?.errors); for (let i = 0; i < inputs.length; i++) { const input = inputs[i]; @@ -202,17 +226,14 @@ export function useInputs(options: { init: Initializer }): { values[key] = initializeValue(input, dataInit?.values?.[key]); } - let errors = dataInit?.errors; - - if (errors != null) { + if (isErrorSet) { if (process.env.NODE_ENV === "development") { console.log( "You explicitly set errors (not undefined) in initializer, so validator won't run.", ); } - errors = cleanObject(errors); } else { - errors = validator?.(values, inputs) ?? {}; + errors = validate(validator, values, inputs); } return { @@ -272,7 +293,7 @@ export function useInputs(options: { init: Initializer }): { const { key } = input; const newValues = { ...data.values, [key]: value }; const newDirties = { ...data.dirties, [key]: true }; - const newErrors = validator?.(newValues, scheme.inputs) ?? {}; + const newErrors = validate(validator, newValues, scheme.inputs); setState({ scheme, data: { @@ -288,7 +309,7 @@ export function useInputs(options: { init: Initializer }): { hasErrorAndDirty: hasError && hasDirty, confirm() { const newDirties = createAllDirties(); - const newErrors = validator?.(data.values, scheme.inputs) ?? {}; + const newErrors = validate(validator, data.values, scheme.inputs); setState({ scheme, |