diff options
author | crupest <crupest@outlook.com> | 2023-07-31 00:08:48 +0800 |
---|---|---|
committer | crupest <crupest@outlook.com> | 2023-07-31 00:09:12 +0800 |
commit | d9c1d512fa64ef4f8c08ca34f7a5842642879bcc (patch) | |
tree | e71cd57b30ed67462ecaf010812b6d902c7fa675 /FrontEnd/src/views | |
parent | 538d6830a0022b49b99695095d85e567b0c86e71 (diff) | |
download | timeline-d9c1d512fa64ef4f8c08ca34f7a5842642879bcc.tar.gz timeline-d9c1d512fa64ef4f8c08ca34f7a5842642879bcc.tar.bz2 timeline-d9c1d512fa64ef4f8c08ca34f7a5842642879bcc.zip |
...
Diffstat (limited to 'FrontEnd/src/views')
25 files changed, 0 insertions, 2229 deletions
diff --git a/FrontEnd/src/views/timeline/CollapseButton.tsx b/FrontEnd/src/views/timeline/CollapseButton.tsx deleted file mode 100644 index 374ccc2e..00000000 --- a/FrontEnd/src/views/timeline/CollapseButton.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from "react"; - -import IconButton from "../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/views/timeline/ConnectionStatusBadge.css b/FrontEnd/src/views/timeline/ConnectionStatusBadge.css deleted file mode 100644 index 7fe83b9b..00000000 --- a/FrontEnd/src/views/timeline/ConnectionStatusBadge.css +++ /dev/null @@ -1,36 +0,0 @@ -.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/views/timeline/ConnectionStatusBadge.tsx b/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx deleted file mode 100644 index 2b820454..00000000 --- a/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx +++ /dev/null @@ -1,41 +0,0 @@ -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/views/timeline/MarkdownPostEdit.css b/FrontEnd/src/views/timeline/MarkdownPostEdit.css deleted file mode 100644 index e36be992..00000000 --- a/FrontEnd/src/views/timeline/MarkdownPostEdit.css +++ /dev/null @@ -1,21 +0,0 @@ -.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/views/timeline/MarkdownPostEdit.tsx b/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx deleted file mode 100644 index 6401cfaa..00000000 --- a/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx +++ /dev/null @@ -1,215 +0,0 @@ -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 "../common/button/FlatButton"; -import TabPages from "../common/tab/TabPages"; -import ConfirmDialog from "../common/dialog/ConfirmDialog"; -import Spinner from "../common/Spinner"; -import IconButton from "../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/views/timeline/PostPropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx deleted file mode 100644 index fc55185c..00000000 --- a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import * as React from "react"; - -import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; - -import OperationDialog from "../common/dialog/OperationDialog"; - -function PostPropertyChangeDialog(props: { - open: boolean; - onClose: () => void; - post: HttpTimelinePostInfo; - onSuccess: (post: HttpTimelinePostInfo) => void; -}): React.ReactElement | null { - const { open, onClose, post, onSuccess } = props; - - return ( - <OperationDialog - title="timeline.changePostPropertyDialog.title" - onClose={onClose} - open={open} - inputScheme={[ - { - label: "timeline.changePostPropertyDialog.time", - type: "datetime", - initValue: post.time, - }, - ]} - onProcess={([time]) => { - return getHttpTimelineClient().patchPost( - post.timelineOwnerV2, - post.timelineNameV2, - post.id, - { - time: time === "" ? undefined : new Date(time).toISOString(), - } - ); - }} - onSuccessAndClose={onSuccess} - /> - ); -} - -export default PostPropertyChangeDialog; diff --git a/FrontEnd/src/views/timeline/Timeline.css b/FrontEnd/src/views/timeline/Timeline.css deleted file mode 100644 index 4dd4fdcc..00000000 --- a/FrontEnd/src/views/timeline/Timeline.css +++ /dev/null @@ -1,244 +0,0 @@ -.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/views/timeline/Timeline.tsx b/FrontEnd/src/views/timeline/Timeline.tsx deleted file mode 100644 index 3a7fbd00..00000000 --- a/FrontEnd/src/views/timeline/Timeline.tsx +++ /dev/null @@ -1,207 +0,0 @@ -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/views/timeline/TimelineCard.tsx b/FrontEnd/src/views/timeline/TimelineCard.tsx deleted file mode 100644 index fdf7f0a0..00000000 --- a/FrontEnd/src/views/timeline/TimelineCard.tsx +++ /dev/null @@ -1,167 +0,0 @@ -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 "../common/user/UserAvatar"; -import PopupMenu from "../common/menu/PopupMenu"; -import FullPageDialog from "../common/dialog/FullPageDialog"; -import Card from "../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 "../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/views/timeline/TimelineDateLabel.tsx b/FrontEnd/src/views/timeline/TimelineDateLabel.tsx deleted file mode 100644 index 5f4ac706..00000000 --- a/FrontEnd/src/views/timeline/TimelineDateLabel.tsx +++ /dev/null @@ -1,19 +0,0 @@ -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/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx deleted file mode 100644 index c960b3c2..00000000 --- a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx +++ /dev/null @@ -1,61 +0,0 @@ -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 "../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" - themeColor="danger" - inputPrompt={() => { - return ( - <Trans - i18nKey="timeline.deleteDialog.inputPrompt" - values={{ name: timeline.nameV2 }} - > - 0<code className="mx-2">1</code>2 - </Trans> - ); - }} - inputScheme={[ - { - type: "text", - }, - ]} - inputValidator={([value]) => { - if (value !== timeline.nameV2) { - return { 0: "timeline.deleteDialog.notMatch" }; - } else { - return null; - } - }} - onProcess={() => { - return getHttpTimelineClient().deleteTimeline( - timeline.owner.username, - timeline.nameV2 - ); - }} - onSuccessAndClose={() => { - navigate("/", { replace: true }); - }} - /> - ); -}; - -export default TimelineDeleteDialog; diff --git a/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx b/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx deleted file mode 100644 index 5e0728d4..00000000 --- a/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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/views/timeline/TimelineLine.tsx b/FrontEnd/src/views/timeline/TimelineLine.tsx deleted file mode 100644 index 4a87e6e0..00000000 --- a/FrontEnd/src/views/timeline/TimelineLine.tsx +++ /dev/null @@ -1,51 +0,0 @@ -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/views/timeline/TimelineLoading.tsx b/FrontEnd/src/views/timeline/TimelineLoading.tsx deleted file mode 100644 index f876cba9..00000000 --- a/FrontEnd/src/views/timeline/TimelineLoading.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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/views/timeline/TimelineMember.css b/FrontEnd/src/views/timeline/TimelineMember.css deleted file mode 100644 index adb78764..00000000 --- a/FrontEnd/src/views/timeline/TimelineMember.css +++ /dev/null @@ -1,8 +0,0 @@ -.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/views/timeline/TimelineMember.tsx b/FrontEnd/src/views/timeline/TimelineMember.tsx deleted file mode 100644 index aaafd173..00000000 --- a/FrontEnd/src/views/timeline/TimelineMember.tsx +++ /dev/null @@ -1,202 +0,0 @@ -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 "../common/SearchInput"; -import UserAvatar from "../common/user/UserAvatar"; -import Button from "../common/button/Button"; -import Dialog from "../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/views/timeline/TimelinePostContentView.tsx b/FrontEnd/src/views/timeline/TimelinePostContentView.tsx deleted file mode 100644 index 9ed192e5..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostContentView.tsx +++ /dev/null @@ -1,187 +0,0 @@ -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 "../common/Skeleton"; -import LoadFailReload from "../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/views/timeline/TimelinePostEdit.css b/FrontEnd/src/views/timeline/TimelinePostEdit.css deleted file mode 100644 index 9b7629e2..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostEdit.css +++ /dev/null @@ -1,10 +0,0 @@ -.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/views/timeline/TimelinePostEdit.tsx b/FrontEnd/src/views/timeline/TimelinePostEdit.tsx deleted file mode 100644 index 38e72264..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostEdit.tsx +++ /dev/null @@ -1,267 +0,0 @@ -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 "../common/BlobImage"; -import LoadingButton from "../common/button/LoadingButton"; -import PopupMenu from "../common/menu/PopupMenu"; -import MarkdownPostEdit from "./MarkdownPostEdit"; -import TimelinePostEditCard from "./TimelinePostEditCard"; -import IconButton from "../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/views/timeline/TimelinePostEditCard.tsx b/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx deleted file mode 100644 index d2f7bd72..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; - -import Card from "../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/views/timeline/TimelinePostEditNoLogin.tsx b/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx deleted file mode 100644 index 1ef0a287..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx +++ /dev/null @@ -1,18 +0,0 @@ -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/views/timeline/TimelinePostListView.tsx b/FrontEnd/src/views/timeline/TimelinePostListView.tsx deleted file mode 100644 index f878b004..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostListView.tsx +++ /dev/null @@ -1,76 +0,0 @@ -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/views/timeline/TimelinePostView.tsx b/FrontEnd/src/views/timeline/TimelinePostView.tsx deleted file mode 100644 index e3eac0f4..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostView.tsx +++ /dev/null @@ -1,159 +0,0 @@ -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 "../common/user/UserAvatar"; -import Card from "../common/Card"; -import FlatButton from "../common/button/FlatButton"; -import ConfirmDialog from "../common/dialog/ConfirmDialog"; -import TimelineLine from "./TimelineLine"; -import TimelinePostContentView from "./TimelinePostContentView"; -import PostPropertyChangeDialog from "./PostPropertyChangeDialog"; -import IconButton from "../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 - ref={cardRef} - className="timeline-item-card enter-animation" - style={cardStyle} - > - {post.editable ? ( - <IconButton - icon="chevron-down" - color="primary-enhance" - 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", - }); - }); - }} - /> - <PostPropertyChangeDialog - open={dialog === "changeproperty"} - onClose={() => { - setDialog(null); - setOperationMaskVisible(false); - }} - post={post} - onSuccess={onChanged} - /> - </div> - ); -}; - -export default TimelinePostView; diff --git a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx deleted file mode 100644 index bd5bef4c..00000000 --- a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import * as React from "react"; - -import { - getHttpTimelineClient, - HttpTimelineInfo, - HttpTimelinePatchRequest, - kTimelineVisibilities, - TimelineVisibility, -} from "@/http/timeline"; - -import OperationDialog from "../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"} - inputScheme={ - [ - { - type: "text", - label: "timeline.dialogChangeProperty.titleField", - initValue: timeline.title, - }, - { - type: "select", - label: "timeline.dialogChangeProperty.visibility", - options: kTimelineVisibilities.map((v) => ({ - label: labelMap[v], - value: v, - })), - initValue: timeline.visibility, - }, - { - type: "text", - label: "timeline.dialogChangeProperty.description", - initValue: timeline.description, - }, - ] as const - } - open={props.open} - onClose={props.close} - onProcess={([newTitle, newVisibility, newDescription, newColor]) => { - const req: HttpTimelinePatchRequest = {}; - if (newTitle !== timeline.title) { - req.title = newTitle; - } - if (newVisibility !== timeline.visibility) { - req.visibility = newVisibility as TimelineVisibility; - } - if (newDescription !== timeline.description) { - req.description = newDescription; - } - const nc = newColor ?? ""; - if (nc !== timeline.color) { - req.color = nc; - } - return getHttpTimelineClient() - .patchTimeline(timeline.owner.username, timeline.nameV2, req) - .then(onChange); - }} - /> - ); -}; - -export default TimelinePropertyChangeDialog; diff --git a/FrontEnd/src/views/timeline/index.tsx b/FrontEnd/src/views/timeline/index.tsx deleted file mode 100644 index 1dffdcc1..00000000 --- a/FrontEnd/src/views/timeline/index.tsx +++ /dev/null @@ -1,23 +0,0 @@ -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; |