mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
feat: Add reading time on pinned documents
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { subDays } from "date-fns";
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -18,6 +19,7 @@ import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import Text from "./Text";
|
||||
import Tooltip from "./Tooltip";
|
||||
@@ -70,6 +72,10 @@ function DocumentCard(props: Props) {
|
||||
[pin]
|
||||
);
|
||||
|
||||
// If the document was updated within the last 7 days, show a timestamp instead of reading time
|
||||
const isRecentlyUpdated =
|
||||
new Date(document.updatedAt) > subDays(new Date(), 7);
|
||||
|
||||
return (
|
||||
<Reorderable
|
||||
ref={setNodeRef}
|
||||
@@ -142,8 +148,14 @@ function DocumentCard(props: Props) {
|
||||
: document.titleWithDefault}
|
||||
</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
{isRecentlyUpdated ? (
|
||||
<>
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</>
|
||||
) : (
|
||||
<ReadingTime document={document} />
|
||||
)}
|
||||
</DocumentMeta>
|
||||
</div>
|
||||
</Content>
|
||||
@@ -164,6 +176,21 @@ function DocumentCard(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const { t } = useTranslation();
|
||||
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
|
||||
const stats = useTextStats(markdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EyeIcon size={18} />
|
||||
{t(`{{ minutes }}m read`, {
|
||||
minutes: stats.total.readingTime,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentSquircle = ({
|
||||
icon,
|
||||
color,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import emojiRegex from "emoji-regex";
|
||||
|
||||
/**
|
||||
* Hook to calculate text statistics
|
||||
* @param text The string to calculate statistics for
|
||||
* @param selectedText A substring of the text to calculate statistics for
|
||||
* @returns An object containing total and selected statistics
|
||||
*/
|
||||
export function useTextStats(text: string, selectedText: string = "") {
|
||||
const numTotalWords = countWords(text);
|
||||
const regex = emojiRegex();
|
||||
const matches = Array.from(text.matchAll(regex));
|
||||
|
||||
return {
|
||||
total: {
|
||||
words: numTotalWords,
|
||||
characters: text.length,
|
||||
emoji: matches.length ?? 0,
|
||||
readingTime: Math.max(1, Math.floor(numTotalWords / 200)),
|
||||
},
|
||||
selected: {
|
||||
words: countWords(selectedText),
|
||||
characters: selectedText.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
const t = text.trim();
|
||||
|
||||
// Hyphenated words are counted as two words
|
||||
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import emojiRegex from "emoji-regex";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -20,6 +19,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useTextSelection from "~/hooks/useTextSelection";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import InsightsMenu from "~/menus/InsightsMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
@@ -213,32 +213,6 @@ function Insights() {
|
||||
);
|
||||
}
|
||||
|
||||
function useTextStats(text: string, selectedText: string) {
|
||||
const numTotalWords = countWords(text);
|
||||
const regex = emojiRegex();
|
||||
const matches = Array.from(text.matchAll(regex));
|
||||
|
||||
return {
|
||||
total: {
|
||||
words: numTotalWords,
|
||||
characters: text.length,
|
||||
emoji: matches.length ?? 0,
|
||||
readingTime: Math.max(1, Math.floor(numTotalWords / 200)),
|
||||
},
|
||||
selected: {
|
||||
words: countWords(selectedText),
|
||||
characters: selectedText.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
const t = text.trim();
|
||||
|
||||
// Hyphenated words are counted as two words
|
||||
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
|
||||
}
|
||||
|
||||
const ListSpacing = styled("div")`
|
||||
margin-top: -0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"Install now": "Install now",
|
||||
"Deleted Collection": "Deleted Collection",
|
||||
"Unpin": "Unpin",
|
||||
"{{ minutes }}m read": "{{ minutes }}m read",
|
||||
"Select a location to copy": "Select a location to copy",
|
||||
"Document copied": "Document copied",
|
||||
"Couldn’t copy the document, try again?": "Couldn’t copy the document, try again?",
|
||||
|
||||
Reference in New Issue
Block a user