feat: use @github/relative-time-element to display time

This commit is contained in:
Steven 2024-03-13 23:47:34 +08:00
parent e795149186
commit ded4da07a3
11 changed files with 64 additions and 158 deletions

View File

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.4", "@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@github/relative-time-element": "^4.3.1",
"@matejmazur/react-katex": "^3.1.3", "@matejmazur/react-katex": "^3.1.3",
"@mui/joy": "5.0.0-beta.30", "@mui/joy": "5.0.0-beta.30",
"@reduxjs/toolkit": "^1.9.7", "@reduxjs/toolkit": "^1.9.7",

7
web/pnpm-lock.yaml generated
View File

@ -14,6 +14,9 @@ dependencies:
'@emotion/styled': '@emotion/styled':
specifier: ^11.11.0 specifier: ^11.11.0
version: 11.11.0(@emotion/react@11.11.4)(@types/react@18.2.63)(react@18.2.0) version: 11.11.0(@emotion/react@11.11.4)(@types/react@18.2.63)(react@18.2.0)
'@github/relative-time-element':
specifier: ^4.3.1
version: 4.3.1
'@matejmazur/react-katex': '@matejmazur/react-katex':
specifier: ^3.1.3 specifier: ^3.1.3
version: 3.1.3(katex@0.16.9)(react@18.2.0) version: 3.1.3(katex@0.16.9)(react@18.2.0)
@ -905,6 +908,10 @@ packages:
resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==}
dev: false dev: false
/@github/relative-time-element@4.3.1:
resolution: {integrity: sha512-zL79nlhZVCg7x2Pf/HT5MB0mowmErE71VXpF10/3Wy8dQwkninNO1M9aOizh2wKC5LkSpDXqNYjDZwbH0/bcSg==}
dev: false
/@humanwhocodes/config-array@0.11.14: /@humanwhocodes/config-array@0.11.14:
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}

View File

@ -1,22 +0,0 @@
import { useTranslate } from "@/utils/i18n";
interface Props {
className?: string;
}
const BetaBadge: React.FC<Props> = (props: Props) => {
const { className } = props;
const t = useTranslate();
return (
<span
className={`mx-1 px-1 leading-5 text-xs border font-normal dark:border-zinc-600 rounded-full text-gray-500 dark:text-gray-400 ${
className ?? ""
}`}
>
{t("common.beta")}
</span>
);
};
export default BetaBadge;

View File

@ -1,7 +1,9 @@
import { Dropdown, Menu, MenuButton, MenuItem } from "@mui/joy"; import { Dropdown, Menu, MenuButton, MenuItem } from "@mui/joy";
import classNames from "classnames"; import classNames from "classnames";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useLocation } from "react-router-dom";
import Icon from "@/components/Icon"; import Icon from "@/components/Icon";
import useNavigateTo from "@/hooks/useNavigateTo";
import { useMemoStore } from "@/store/v1"; import { useMemoStore } from "@/store/v1";
import { RowStatus } from "@/types/proto/api/v2/common"; import { RowStatus } from "@/types/proto/api/v2/common";
import { Memo } from "@/types/proto/api/v2/memo_service"; import { Memo } from "@/types/proto/api/v2/memo_service";
@ -14,14 +16,15 @@ interface Props {
memo: Memo; memo: Memo;
className?: string; className?: string;
hiddenActions?: ("edit" | "archive" | "delete" | "share" | "pin")[]; hiddenActions?: ("edit" | "archive" | "delete" | "share" | "pin")[];
onArchived?: () => void;
onDeleted?: () => void;
} }
const MemoActionMenu = (props: Props) => { const MemoActionMenu = (props: Props) => {
const { memo, hiddenActions } = props; const { memo, hiddenActions } = props;
const t = useTranslate(); const t = useTranslate();
const location = useLocation();
const navigateTo = useNavigateTo();
const memoStore = useMemoStore(); const memoStore = useMemoStore();
const isInMemoDetailPage = location.pathname.startsWith(`/m/${memo.name}`);
const handleTogglePinMemoBtnClick = async () => { const handleTogglePinMemoBtnClick = async () => {
try { try {
@ -66,9 +69,12 @@ const MemoActionMenu = (props: Props) => {
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.response.data.message); toast.error(error.response.data.message);
return;
} }
if (props.onArchived) {
props.onArchived(); toast.success("Archived successfully");
if (isInMemoDetailPage) {
navigateTo("/archived");
} }
}; };
@ -80,8 +86,9 @@ const MemoActionMenu = (props: Props) => {
dialogName: "delete-memo-dialog", dialogName: "delete-memo-dialog",
onConfirm: async () => { onConfirm: async () => {
await memoStore.deleteMemo(memo.id); await memoStore.deleteMemo(memo.id);
if (props.onDeleted) { toast.success("Deleted successfully");
props.onDeleted(); if (isInMemoDetailPage) {
navigateTo("/");
} }
}, },
}); });

View File

@ -2,7 +2,6 @@ import { useContext, useEffect } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import Icon from "@/components/Icon"; import Icon from "@/components/Icon";
import MemoResourceListView from "@/components/MemoResourceListView"; import MemoResourceListView from "@/components/MemoResourceListView";
import { getDateTimeString } from "@/helpers/datetime";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import { useMemoStore } from "@/store/v1"; import { useMemoStore } from "@/store/v1";
import MemoContent from ".."; import MemoContent from "..";
@ -51,7 +50,9 @@ const EmbeddedMemo = ({ resourceId, params: paramsStr }: Props) => {
return ( return (
<div className="relative flex flex-col justify-start items-start w-full p-4 pt-3 !my-2 bg-white dark:bg-zinc-800 rounded-lg border border-gray-200 dark:border-zinc-700 hover:shadow"> <div className="relative flex flex-col justify-start items-start w-full p-4 pt-3 !my-2 bg-white dark:bg-zinc-800 rounded-lg border border-gray-200 dark:border-zinc-700 hover:shadow">
<div className="w-full mb-1 flex flex-row justify-between items-center"> <div className="w-full mb-1 flex flex-row justify-between items-center">
<span className="text-sm text-gray-400 select-none">{getDateTimeString(memo.displayTime)}</span> <div className="text-sm leading-6 text-gray-400 select-none">
<relative-time datetime={memo.displayTime?.toISOString()} tense="past"></relative-time>
</div>
<Link className="hover:opacity-80" to={`/m/${memo.name}`} unstable_viewTransition> <Link className="hover:opacity-80" to={`/m/${memo.name}`} unstable_viewTransition>
<Icon.ArrowUpRight className="w-5 h-auto opacity-80 text-gray-400" /> <Icon.ArrowUpRight className="w-5 h-auto opacity-80 text-gray-400" />
</Link> </Link>

View File

@ -74,7 +74,7 @@ const MemoContent: React.FC<Props> = (props: Props) => {
<div <div
ref={memoContentContainerRef} ref={memoContentContainerRef}
className={classNames( className={classNames(
"w-full max-w-full word-break text-base leading-6 space-y-1 whitespace-pre-wrap", "w-full max-w-full word-break text-base leading-7 space-y-1 whitespace-pre-wrap",
showCompactMode && "line-clamp-6", showCompactMode && "line-clamp-6",
)} )}
onClick={handleMemoContentClick} onClick={handleMemoContentClick}

View File

@ -1,9 +1,7 @@
import { Tooltip } from "@mui/joy"; import { Tooltip } from "@mui/joy";
import classNames from "classnames"; import classNames from "classnames";
import { memo, useCallback, useEffect, useRef, useState } from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { Link, useLocation } from "react-router-dom";
import { Link } from "react-router-dom";
import { getRelativeTimeString, getTimeStampByDate } from "@/helpers/datetime";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { useUserStore, extractUsernameFromName } from "@/store/v1"; import { useUserStore, extractUsernameFromName } from "@/store/v1";
@ -25,6 +23,7 @@ import VisibilityIcon from "./VisibilityIcon";
interface Props { interface Props {
memo: Memo; memo: Memo;
compact?: boolean;
showVisibility?: boolean; showVisibility?: boolean;
showPinned?: boolean; showPinned?: boolean;
className?: string; className?: string;
@ -33,12 +32,11 @@ interface Props {
const MemoView: React.FC<Props> = (props: Props) => { const MemoView: React.FC<Props> = (props: Props) => {
const { memo, className } = props; const { memo, className } = props;
const t = useTranslate(); const t = useTranslate();
const location = useLocation();
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const { i18n } = useTranslation();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const userStore = useUserStore(); const userStore = useUserStore();
const user = useCurrentUser(); const user = useCurrentUser();
const [displayTime, setDisplayTime] = useState<string>(getRelativeTimeString(getTimeStampByDate(memo.displayTime)));
const [creator, setCreator] = useState(userStore.getUserByUsername(extractUsernameFromName(memo.creator))); const [creator, setCreator] = useState(userStore.getUserByUsername(extractUsernameFromName(memo.creator)));
const memoContainerRef = useRef<HTMLDivElement>(null); const memoContainerRef = useRef<HTMLDivElement>(null);
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE); const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
@ -46,6 +44,7 @@ const MemoView: React.FC<Props> = (props: Props) => {
(relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemoId === memo.id, (relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemoId === memo.id,
).length; ).length;
const readonly = memo.creator !== user?.name; const readonly = memo.creator !== user?.name;
const isInMemoDetailPage = location.pathname.startsWith(`/m/${memo.name}`);
// Initial related data: creator. // Initial related data: creator.
useEffect(() => { useEffect(() => {
@ -55,20 +54,6 @@ const MemoView: React.FC<Props> = (props: Props) => {
})(); })();
}, []); }, []);
// Update display time string.
useEffect(() => {
let intervalFlag: any = -1;
if (Date.now() - getTimeStampByDate(memo.displayTime) < 1000 * 60 * 60 * 24) {
intervalFlag = setInterval(() => {
setDisplayTime(getRelativeTimeString(getTimeStampByDate(memo.displayTime)));
}, 1000 * 1);
}
return () => {
clearInterval(intervalFlag);
};
}, [i18n.language]);
const handleGotoMemoDetailPage = (event: React.MouseEvent<HTMLDivElement>) => { const handleGotoMemoDetailPage = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.altKey) { if (event.altKey) {
showChangeMemoCreatedTsDialog(memo.id); showChangeMemoCreatedTsDialog(memo.id);
@ -124,8 +109,8 @@ const MemoView: React.FC<Props> = (props: Props) => {
</div> </div>
)} )}
</div> </div>
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-1"> <div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">
<div className="w-auto invisible group-hover:visible flex flex-row justify-between items-center gap-1"> <div className="w-auto invisible group-hover:visible flex flex-row justify-between items-center gap-2">
{props.showVisibility && memo.visibility !== Visibility.PRIVATE && ( {props.showVisibility && memo.visibility !== Visibility.PRIVATE && (
<Tooltip title={t(`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}` as any)} placement="top"> <Tooltip title={t(`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}` as any)} placement="top">
<span className="flex justify-center items-center hover:opacity-70"> <span className="flex justify-center items-center hover:opacity-70">
@ -133,25 +118,27 @@ const MemoView: React.FC<Props> = (props: Props) => {
</span> </span>
</Tooltip> </Tooltip>
)} )}
{currentUser && <ReactionSelector className="border-none" memo={memo} />} {currentUser && <ReactionSelector className="border-none w-auto h-auto" memo={memo} />}
</div> </div>
<Link {!isInMemoDetailPage && (
className={classNames( <Link
"flex flex-row justify-start items-center hover:opacity-70", className={classNames(
commentAmount === 0 && "invisible group-hover:visible", "flex flex-row justify-start items-center hover:opacity-70",
)} commentAmount === 0 && "invisible group-hover:visible",
to={`/m/${memo.name}#comments`} )}
unstable_viewTransition to={`/m/${memo.name}#comments`}
> unstable_viewTransition
<Icon.MessageCircleMore className="w-4 h-4 mx-auto text-gray-500 dark:text-gray-400" /> >
{commentAmount > 0 && <span className="text-xs text-gray-500 dark:text-gray-400">{commentAmount}</span>} <Icon.MessageCircleMore className="w-4 h-4 mx-auto text-gray-500 dark:text-gray-400" />
</Link> {commentAmount > 0 && <span className="text-xs text-gray-500 dark:text-gray-400">{commentAmount}</span>}
</Link>
)}
{props.showPinned && memo.pinned && ( {props.showPinned && memo.pinned && (
<Tooltip title={"Pinned"} placement="top"> <Tooltip title={"Pinned"} placement="top">
<Icon.Bookmark className="ml-1 w-4 h-auto text-amber-500" /> <Icon.Bookmark className="w-4 h-auto text-amber-500" />
</Tooltip> </Tooltip>
)} )}
{!readonly && <MemoActionMenu memo={memo} hiddenActions={props.showPinned ? [] : ["pin"]} />} {!readonly && <MemoActionMenu className="-ml-1" memo={memo} hiddenActions={props.showPinned ? [] : ["pin"]} />}
</div> </div>
</div> </div>
<MemoContent <MemoContent
@ -160,13 +147,13 @@ const MemoView: React.FC<Props> = (props: Props) => {
content={memo.content} content={memo.content}
readonly={readonly} readonly={readonly}
onClick={handleMemoContentClick} onClick={handleMemoContentClick}
compact={true} compact={props.compact ?? true}
/> />
<MemoResourceListView resources={memo.resources} /> <MemoResourceListView resources={memo.resources} />
<div className="w-full flex flex-row justify-between items-center mt-1"> <div className="w-full flex flex-row justify-between items-center">
<span className="text-sm leading-6 text-gray-400 select-none" onClick={handleGotoMemoDetailPage}> <div className="text-sm leading-6 text-gray-400 select-none">
{displayTime} <relative-time datetime={memo.displayTime?.toISOString()} tense="past" onClick={handleGotoMemoDetailPage}></relative-time>
</span> </div>
</div> </div>
<MemoRelationListView memo={memo} relations={referencedMemos} /> <MemoRelationListView memo={memo} relations={referencedMemos} />
<MemoReactionistView memo={memo} reactions={memo.reactions} /> <MemoReactionistView memo={memo} reactions={memo.reactions} />

View File

@ -4,13 +4,6 @@ export function getTimeStampByDate(t: Date | number | string | any): number {
return new Date(t).getTime(); return new Date(t).getTime();
} }
export function getDateStampByDate(t?: Date | number | string): number {
const tsFromDate = getTimeStampByDate(t ? t : Date.now());
const d = new Date(tsFromDate);
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}
/** /**
* Get a time string to provided time. * Get a time string to provided time.
* *
@ -57,56 +50,6 @@ export function getDateTimeString(t?: Date | number | string | any, locale = i18
} }
} }
/**
* Get a localized relative time string to provided time.
*
* Possible outputs for "long" format and "en" locale:
* - "x seconds ago"
* - "x minutes ago"
* - "x hours ago"
* - "yesterday"
* - "x days ago"
* - "x weeks ago"
* - "x months ago"
* - "last year"
* - "x years ago"
*/
export const getRelativeTimeString = (time: number, locale = i18n.language, formatStyle: "long" | "short" | "narrow" = "long"): string => {
const pastTimeMillis = Date.now() - time;
const secMillis = 1000;
const minMillis = secMillis * 60;
const hourMillis = minMillis * 60;
const dayMillis = hourMillis * 24;
// Show full date if more than 1 day ago.
if (pastTimeMillis >= dayMillis) {
return getDateTimeString(time, locale);
}
// numeric: "auto" provides "yesterday" for 1 day ago, "always" provides "1 day ago"
const formatOpts = { style: formatStyle, numeric: "auto" } as Intl.RelativeTimeFormatOptions;
const relTime = new Intl.RelativeTimeFormat(locale, formatOpts);
if (pastTimeMillis < minMillis) {
return relTime.format(-Math.round(pastTimeMillis / secMillis), "second");
}
if (pastTimeMillis < hourMillis) {
return relTime.format(-Math.round(pastTimeMillis / minMillis), "minute");
}
if (pastTimeMillis < dayMillis) {
return relTime.format(-Math.round(pastTimeMillis / hourMillis), "hour");
}
if (pastTimeMillis < dayMillis * 7) {
return relTime.format(-Math.round(pastTimeMillis / dayMillis), "day");
}
if (pastTimeMillis < dayMillis * 30) {
return relTime.format(-Math.round(pastTimeMillis / (dayMillis * 7)), "week");
}
if (pastTimeMillis < dayMillis * 365) {
return relTime.format(-Math.round(pastTimeMillis / (dayMillis * 30)), "month");
}
return relTime.format(-Math.round(pastTimeMillis / (dayMillis * 365)), "year");
};
/** /**
* This returns the normalized date string of the provided date. * This returns the normalized date string of the provided date.
* Format is always `YYYY-MM-DDT00:00`. * Format is always `YYYY-MM-DDT00:00`.
@ -143,33 +86,6 @@ export function getNormalizedDateString(t?: Date | number | string): string {
return `${yyyy}-${MM}-${dd}`; return `${yyyy}-${MM}-${dd}`;
} }
/**
* This returns the Unix timestamp (the number of **seconds** since the Unix Epoch) of the provided date.
*
* If no date is provided, the current date is used.
* ```
* getUnixTime("2019-01-25 00:00") // 1548381600
* ```
* This value is floored to the nearest second, and does not include a milliseconds component.
*/
export function getUnixTime(t?: Date | number | string): number {
const date = new Date(t ? t : Date.now());
return Math.floor(date.getTime() / 1000);
}
/**
* Checks if the provided date or timestamp is in the future.
*
* If no date is provided, the current date is used.
*
* @param t - Date or timestamp to check.
* @returns `true` if the date is in the future, `false` otherwise.
*/
export function isFutureDate(t?: Date | number | string): boolean {
const timestamp = getTimeStampByDate(t ? t : Date.now());
return timestamp > Date.now();
}
/** /**
* Calculates a new Date object by adjusting the provided date, timestamp, or date string * Calculates a new Date object by adjusting the provided date, timestamp, or date string
* based on the current timezone offset. * based on the current timezone offset.

View File

@ -1,3 +1,4 @@
import "@github/relative-time-element";
import { CssVarsProvider } from "@mui/joy"; import { CssVarsProvider } from "@mui/joy";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";

View File

@ -11,7 +11,6 @@ import MobileHeader from "@/components/MobileHeader";
import SearchBar from "@/components/SearchBar"; import SearchBar from "@/components/SearchBar";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import { getTimeStampByDate } from "@/helpers/datetime"; import { getTimeStampByDate } from "@/helpers/datetime";
import { getDateTimeString } from "@/helpers/datetime";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useFilterWithUrlParams from "@/hooks/useFilterWithUrlParams"; import useFilterWithUrlParams from "@/hooks/useFilterWithUrlParams";
import { useMemoList, useMemoStore } from "@/store/v1"; import { useMemoList, useMemoStore } from "@/store/v1";
@ -105,7 +104,9 @@ const Archived = () => {
> >
<div className="w-full mb-1 flex flex-row justify-between items-center"> <div className="w-full mb-1 flex flex-row justify-between items-center">
<div className="w-full max-w-[calc(100%-20px)] flex flex-row justify-start items-center mr-1"> <div className="w-full max-w-[calc(100%-20px)] flex flex-row justify-start items-center mr-1">
<span className="text-sm text-gray-400 select-none">{getDateTimeString(memo.displayTime)}</span> <div className="text-sm leading-6 text-gray-400 select-none">
<relative-time datetime={memo.displayTime?.toISOString()} tense="past"></relative-time>
</div>
</div> </div>
<div className="flex flex-row justify-end items-center gap-x-2"> <div className="flex flex-row justify-end items-center gap-x-2">
<Tooltip title={t("common.restore")} placement="top"> <Tooltip title={t("common.restore")} placement="top">

View File

@ -81,7 +81,14 @@ const MemoDetail = () => {
</Link> </Link>
</div> </div>
)} )}
<MemoView key={`${memo.id}-${memo.displayTime}`} className="shadow hover:shadow-xl transition-all" memo={memo} /> <MemoView
key={`${memo.id}-${memo.displayTime}`}
className="shadow hover:shadow-xl transition-all"
memo={memo}
compact={false}
showVisibility
showPinned
/>
<div className="pt-8 pb-16 w-full"> <div className="pt-8 pb-16 w-full">
<h2 id="comments" className="sr-only"> <h2 id="comments" className="sr-only">
Comments Comments