diff options
Diffstat (limited to 'FrontEnd/src/app/views')
7 files changed, 233 insertions, 153 deletions
| 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 | 
