diff options
8 files changed, 236 insertions, 153 deletions
diff --git a/FrontEnd/src/app/index.sass b/FrontEnd/src/app/index.sass index 87616998..85c2bcdc 100644 --- a/FrontEnd/src/app/index.sass +++ b/FrontEnd/src/app/index.sass @@ -88,3 +88,6 @@ textarea .touch-action-none
touch-action: none
+
+i
+ line-height: 1
diff --git a/FrontEnd/src/app/views/timeline-common/Timeline.tsx b/FrontEnd/src/app/views/timeline-common/Timeline.tsx index aba868cb..ab658b89 100644 --- a/FrontEnd/src/app/views/timeline-common/Timeline.tsx +++ b/FrontEnd/src/app/views/timeline-common/Timeline.tsx @@ -5,6 +5,15 @@ import { TimelinePostInfo } from "@/services/timeline"; import TimelineItem from "./TimelineItem"; import TimelineTop from "./TimelineTop"; +import TimelineDateItem from "./TimelineDateItem"; + +function dateEqual(left: Date, right: Date): boolean { + return ( + left.getDate() == right.getDate() && + left.getMonth() == right.getMonth() && + left.getFullYear() == right.getFullYear() + ); +} export interface TimelinePostInfoEx extends TimelinePostInfo { onDelete?: () => void; @@ -16,15 +25,39 @@ export interface TimelineProps { className?: string; style?: React.CSSProperties; posts: TimelinePostInfoEx[]; - onResize?: () => void; containerRef?: React.Ref<HTMLDivElement>; } const Timeline: React.FC<TimelineProps> = (props) => { - const { posts, onResize } = props; + const { posts } = props; const [showMoreIndex, setShowMoreIndex] = React.useState<number>(-1); + const groupedPosts = React.useMemo< + { date: Date; posts: (TimelinePostInfoEx & { index: number })[] }[] + >(() => { + const result: { + date: Date; + posts: (TimelinePostInfoEx & { index: number })[]; + }[] = []; + let index = 0; + for (const post of posts) { + const { time } = post; + 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 ( <div ref={props.containerRef} @@ -32,30 +65,33 @@ const Timeline: React.FC<TimelineProps> = (props) => { className={clsx("timeline", props.className)} > <TimelineTop height="56px" /> - {(() => { - const length = posts.length; - return posts.map((post, index) => { - return ( - <TimelineItem - post={post} - key={post.id} - current={length - 1 === index} - more={ - post.onDelete != null - ? { - isOpen: showMoreIndex === index, - toggle: () => - setShowMoreIndex((old) => (old === index ? -1 : index)), - onDelete: post.onDelete, - } - : undefined - } - onClick={() => setShowMoreIndex(-1)} - onResize={onResize} - /> - ); - }); - })()} + {groupedPosts.map((group) => { + return ( + <> + <TimelineDateItem date={group.date} /> + {group.posts.map((post) => ( + <TimelineItem + post={post} + key={post.id} + current={posts.length - 1 === post.index} + more={ + post.onDelete != null + ? { + isOpen: showMoreIndex === post.index, + toggle: () => + setShowMoreIndex((old) => + old === post.index ? -1 : post.index + ), + onDelete: post.onDelete, + } + : undefined + } + onClick={() => setShowMoreIndex(-1)} + /> + ))} + </> + ); + })} </div> ); }; diff --git a/FrontEnd/src/app/views/timeline-common/TimelineDateItem.tsx b/FrontEnd/src/app/views/timeline-common/TimelineDateItem.tsx new file mode 100644 index 00000000..bcc1530f --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelineDateItem.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import TimelineLine from "./TimelineLine"; + +export interface TimelineDateItemProps { + date: Date; +} + +const TimelineDateItem: React.FC<TimelineDateItemProps> = ({ date }) => { + return ( + <div className="timeline-date-item"> + <TimelineLine center={null} /> + <div className="timeline-date-item-badge"> + {date.toLocaleDateString()} + </div> + </div> + ); +}; + +export default TimelineDateItem; diff --git a/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx b/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx index 233c81bd..c096f890 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx @@ -1,45 +1,13 @@ import React from "react"; import clsx from "clsx"; import { Link } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { Modal, Button } from "react-bootstrap"; import { useAvatar } from "@/services/user"; import { TimelinePostInfo } from "@/services/timeline"; import BlobImage from "../common/BlobImage"; - -const TimelinePostDeleteConfirmDialog: React.FC<{ - onClose: () => void; - onConfirm: () => void; -}> = ({ onClose, onConfirm }) => { - const { t } = useTranslation(); - - return ( - <Modal onHide={onClose} show centered> - <Modal.Header> - <Modal.Title className="text-danger"> - {t("timeline.post.deleteDialog.title")} - </Modal.Title> - </Modal.Header> - <Modal.Body>{t("timeline.post.deleteDialog.prompt")}</Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={onClose}> - {t("operationDialog.cancel")} - </Button> - <Button - variant="danger" - onClick={() => { - onConfirm(); - onClose(); - }} - > - {t("operationDialog.confirm")} - </Button> - </Modal.Footer> - </Modal> - ); -}; +import TimelineLine from "./TimelineLine"; +import TimelinePostDeleteConfirmDialog from "./TimelinePostDeleteConfirmDialog"; export interface TimelineItemProps { post: TimelinePostInfo; @@ -50,17 +18,14 @@ export interface TimelineItemProps { onDelete: () => void; }; onClick?: () => void; - onResize?: () => void; className?: string; style?: React.CSSProperties; } const TimelineItem: React.FC<TimelineItemProps> = (props) => { - const { i18n } = useTranslation(); - const current = props.current === true; - const { more, onResize } = props; + const { more } = props; const avatar = useAvatar(props.post.author.username); @@ -68,53 +33,37 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => { return ( <div - className={clsx( - "timeline-item position-relative", - current && "current", - props.className - )} + className={clsx("timeline-item", current && "current", props.className)} onClick={props.onClick} style={props.style} > - <div className="timeline-line-area-container"> - <div className="timeline-line-area"> - <div className="timeline-line-segment start"></div> - <div className="timeline-line-node-container"> - <div className="timeline-line-node"></div> - </div> - <div className="timeline-line-segment end"></div> - {current && <div className="timeline-line-segment current-end" />} - </div> - </div> + <TimelineLine center="node" current={current} /> <div className="timeline-item-card"> - <div> + {more != null ? ( + <i + className="bi-chevron-down text-info icon-button float-right" + onClick={(e) => { + more.toggle(); + e.stopPropagation(); + }} + /> + ) : null} + <div className="timeline-item-header"> <span className="mr-2"> - <small className="text-secondary white-space-no-wrap mr-2"> - {props.post.time.toLocaleString(i18n.languages)} - </small> - <small className="text-dark">{props.post.author.nickname}</small> + <span> + <Link to={"/users/" + props.post.author.username}> + <BlobImage blob={avatar} className="timeline-avatar mr-1" /> + </Link> + <small className="text-dark mr-2"> + {props.post.author.nickname} + </small> + <small className="text-secondary white-space-no-wrap"> + {props.post.time.toLocaleTimeString()} + </small> + </span> </span> - {more != null ? ( - <i - className="bi-chevron-down text-info icon-button" - onClick={(e) => { - more.toggle(); - e.stopPropagation(); - }} - /> - ) : null} </div> <div className="timeline-content"> - <Link - className="float-left m-2" - to={"/users/" + props.post.author.username} - > - <BlobImage - onLoad={onResize} - blob={avatar} - className="avatar rounded" - /> - </Link> {(() => { const { content } = props.post; if (content.type === "text") { @@ -122,7 +71,6 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => { } else { return ( <BlobImage - onLoad={onResize} blob={content.data} className="timeline-content-image" /> diff --git a/FrontEnd/src/app/views/timeline-common/TimelineLine.tsx b/FrontEnd/src/app/views/timeline-common/TimelineLine.tsx new file mode 100644 index 00000000..fd7dde0a --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelineLine.tsx @@ -0,0 +1,33 @@ +import clsx from "clsx"; +import React from "react"; + +export interface TimelineLineProps { + current?: boolean; + startSegmentLength?: string | number; + center: "node" | null; + className?: string; + style?: React.CSSProperties; +} + +const TimelineLine: React.FC<TimelineLineProps> = ({ + startSegmentLength, + center, + current, + className, + style, +}) => { + return ( + <div className={clsx("timeline-line", className)} style={style}> + <div className="segment start" style={{ height: startSegmentLength }} /> + {center == "node" ? ( + <div className="node-container"> + <div className="node"></div> + </div> + ) : null} + <div className="segment end"></div> + {current && <div className="segment current-end" />} + </div> + ); +}; + +export default TimelineLine; diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx new file mode 100644 index 00000000..b2c7a470 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { Modal, Button } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +const TimelinePostDeleteConfirmDialog: React.FC<{ + onClose: () => void; + onConfirm: () => void; +}> = ({ onClose, onConfirm }) => { + const { t } = useTranslation(); + + return ( + <Modal onHide={onClose} show centered> + <Modal.Header> + <Modal.Title className="text-danger"> + {t("timeline.post.deleteDialog.title")} + </Modal.Title> + </Modal.Header> + <Modal.Body>{t("timeline.post.deleteDialog.prompt")}</Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={onClose}> + {t("operationDialog.cancel")} + </Button> + <Button + variant="danger" + onClick={() => { + onConfirm(); + onClose(); + }} + > + {t("operationDialog.confirm")} + </Button> + </Modal.Footer> + </Modal> + ); +}; + +export default TimelinePostDeleteConfirmDialog; diff --git a/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx b/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx index 93a2a32c..39cfa426 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx @@ -1,5 +1,7 @@ import React from "react"; +import TimelineLine from "./TimelineLine"; + export interface TimelineTopProps { height?: number | string; children?: React.ReactElement; @@ -8,11 +10,7 @@ export interface TimelineTopProps { const TimelineTop: React.FC<TimelineTopProps> = ({ height, children }) => { return ( <div style={{ height: height }} className="timeline-top"> - <div className="timeline-line-area-container"> - <div className="timeline-line-area"> - <div className="timeline-line-segment"></div> - </div> - </div> + <TimelineLine center={null} /> {children} </div> ); diff --git a/FrontEnd/src/app/views/timeline-common/timeline-common.sass b/FrontEnd/src/app/views/timeline-common/timeline-common.sass index 1aa5e731..ebaf96b5 100644 --- a/FrontEnd/src/app/views/timeline-common/timeline-common.sass +++ b/FrontEnd/src/app/views/timeline-common/timeline-common.sass @@ -6,10 +6,6 @@ width: 100% overflow-wrap: break-word - &-item - position: relative - padding: 0.5em - $timeline-line-width: 7px $timeline-line-node-radius: 18px $timeline-line-color: $primary @@ -32,34 +28,28 @@ $timeline-line-color-current: #36c2e6 box-shadow: 0 0 20px 3px color.adjust($timeline-line-color-current, $lightness: +10%, $alpha: -0.1) .timeline-line - &-area-container - position: absolute - display: flex - justify-content: flex-end - padding-right: 5px - z-index: 1 + display: flex + flex-direction: column + align-items: center + width: 30px - top: 0em - bottom: 0em - left: 0.5em - width: 60px - transition: width 0.8s + position: absolute + z-index: 1 + left: 2em + top: 0 + bottom: 0 - @include media-breakpoint-down(sm) - width: 40px + transition: left 0.5s - &-area - display: flex - flex-direction: column - align-items: center - width: 30px + @include media-breakpoint-down(sm) + left: 1em - &-segment + .segment width: $timeline-line-width background: $timeline-line-color &.start - height: 1.4em + height: 1.8em flex: 0 0 auto &.end @@ -70,13 +60,13 @@ $timeline-line-color-current: #36c2e6 flex: 0 0 auto background: linear-gradient($timeline-line-color-current, transparent) - &-node-container + .node-container flex: 0 0 auto position: relative width: $timeline-line-node-radius height: $timeline-line-node-radius - &-node + .node width: $timeline-line-node-radius + 2 height: $timeline-line-node-radius + 2 position: absolute @@ -88,42 +78,49 @@ $timeline-line-color-current: #36c2e6 animation: 1s infinite alternate animation-name: timeline-line-node-noncurrent -.timeline-top - position: relative - text-align: right - - .timeline-line-segment - flex: 1 1 auto - .current &.timeline-item padding-bottom: 2.5em .timeline-line - &-segment - + .segment &.start background: linear-gradient($timeline-line-color, $timeline-line-color-current) - &.end background: $timeline-line-color-current - - &-node + .node animation-name: timeline-line-node-current +.timeline-top + position: relative + text-align: right + +.timeline-item + position: relative + padding: 0.5em + .timeline-item-card @extend .cru-card - @extend .clearfix position: relative - padding: 0.5em 2em 0.5em 60px - transition: background 0.5s, padding-left 0.8s + padding: 0.3em 0.5em 1em 4em + transition: background 0.5s, padding-left 0.5s @include media-breakpoint-down(sm) - padding-left: 40px + padding-left: 3em &:hover background: $gray-200 +.timeline-item-header + display: flex + align-items: center + @extend .my-2 + +.timeline-avatar + border-radius: 50% + width: 2em + height: 2em + .timeline-item-delete-button position: absolute right: 0 @@ -136,6 +133,18 @@ $timeline-line-color-current: #36c2e6 max-width: 60% 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-edit-image max-width: 100px max-height: 100px @@ -171,5 +180,5 @@ $timeline-line-color-current: #36c2e6 justify-content: center .timeline - max-width: 100em - flex-grow: 1
\ No newline at end of file + max-width: 80em + flex-grow: 1 |