aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--FrontEnd/src/app/index.sass3
-rw-r--r--FrontEnd/src/app/views/timeline-common/Timeline.tsx88
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineDateItem.tsx19
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineItem.tsx104
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineLine.tsx33
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx37
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineTop.tsx8
-rw-r--r--FrontEnd/src/app/views/timeline-common/timeline-common.sass97
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