How to Calculate the Reading Time of Body Content with React
17 replies
Last updated: Apr 5, 2022
N
Hi all! Trying to implement the "react-hook-reading-time" to calculate the reading time of the body content. But the problem is it's trying to calculate the body text. But the PortableText body is one array of multiple objects. Is it possible to add this? Can I like filter out the text only from the body array?
S
I have simply add a field with title " reading time " and put reading time myself š if it's help šYou have also a reading time calculator here
https://niram.org/read/
https://niram.org/read/
S
{
name: "read",
title: "read",
type: "number",
description: "put number in minutes for reading timeš",
validation: (Rule) => Rule.required(),
},S
Hey
Schema:
Custom component:
The styling is quite basic, but hopefully this works for you.
user Q
, Iāve made a little custom component that might help!Schema:
import RenderReadingTime from "./RenderReadingTime";
export default {
name: "page",
title: "Pages",
type: "document",
fields: [
{
title: "Content",
name: "content",
type: "array",
of: [{ type: "block" }],
},
{
name: "reading",
title: "Reading Time",
type: "string",
inputComponent: RenderReadingTime,
},
],
};import React from "react";
import { Card, Stack, Inline, Badge } from "@sanity/ui";
import { FormField } from "@sanity/base/components";
import { withDocument } from "part:@sanity/form-builder";
import { useReadingTime } from "react-hook-reading-time";
export const RenderReadingTime = React.forwardRef((props, ref) => {
const { type, document } = props;
const filteredBlock = document.content
.filter((x) => x._type === "block")
.map((children) => children.children.map((texts) => texts.text));
const readingTime = useReadingTime(filteredBlock.join(" "));
return (
<FormField description={type.description} title={type.title}>
<Card padding={[3, 3, 3, 3]} shadow={1} radius={2} ref={ref}>
<Stack space={[3, 3, 3]}>
<Inline space={2}>
<Badge tone={"primary"}>{readingTime.text}</Badge>
<Badge>{`${readingTime.words} words`}</Badge>
</Inline>
</Stack>
</Card>
</FormField>
);
});
export default withDocument(RenderReadingTime);N
Tried doing it dynamically why over complicate things. Thanks!
S
Youāre welcome!
N
user P
Got it working. but inside GROQ I get null if "reading" is added to the query.N
export const articleQuery = groq`*[_type == "article" && slug.current == $slug][0] {
...,
reading,
body[] {
...,
_type == "imageWithAltText" => {
asset -> {
...,
url,
},
},
_type == "video" => {
asset -> {
...,
videoPlatform,
href,
},
},
markDefs[] {
...,
_type == "internalLink" => {
"path": reference->slug.current
}
},
},
'comments': *[
_type == "comment" &&
article._ref == ^._id &&
approved == true
],
user -> {
name,
featuredImage,
},
"slug": slug.current,
}`;S
is not
reading->,?
export const articleQuery = groq`*[_type == "article" && slug.current == $slug][0] {
...,
reading->,
body[] {
...,
_type == "imageWithAltText" => {
asset -> {
...,
url,
},
},
_type == "video" => {
asset -> {
...,
videoPlatform,
href,
},
},
markDefs[] {
...,
_type == "internalLink" => {
"path": reference->slug.current
}
},
},
'comments': *[
_type == "comment" &&
article._ref == ^._id &&
approved == true
],
user -> {
name,
featuredImage,
},
"slug": slug.current,
}`;N
Returns null
S
user Q
At the moment the āreadingā component is just a preview in the studio ā itās not patching the data into the schema. Youāll need to add the PatchEventfrom
"@sanity/form-builder/PatchEvent"into the RenderReadingTime component. I will try and update the component a bit later today!
N
user P
Thank you very much would be nice to have it inside the GROQ instead of having to write same code on the front-end sideN
user P
This is what I have now but GROQ returns 1d.
import React from "react";
import { Card, Stack, Inline, Badge, TextInput } from "@sanity/ui";
import { FormField } from "@sanity/base/components";
import { withDocument } from "part:@sanity/form-builder";
import { useReadingTime } from "react-hook-reading-time";
import { useId } from "@reach/auto-id"; // hook to generate unique IDs
import PatchEvent, { set, unset } from "@sanity/form-builder/PatchEvent";
export const RenderReadingTime = React.forwardRef((props, ref) => {
const {
type,
document,
value,
readOnly,
onChange,
compareValue,
markers,
presence,
} = props;
const filteredBlock = document.body
.filter((x) => x._type === "block")
.map((children) => children.children.map((texts) => texts.text));
const readingTime = useReadingTime(filteredBlock.join(" "));
// Creates a unique ID for our input
const inputId = useId();
// Creates a change handler for patching data
const handleChange = React.useCallback(
// useCallback will help with performance
(event) => {
const inputValue = event.currentTarget.value; // get current value
// if the value exists, set the data, if not, unset the data
onChange(PatchEvent.from(inputValue ? set(inputValue) : unset()));
},
[]
);
return (
<FormField
description={type.description}
title={type.title}
compareValue={compareValue}
inputId={inputId}
__unstable_markers={markers}
__unstable_presence={presence}
>
<Card padding={[3, 3, 3, 3]} shadow={1} radius={2} ref={ref}>
<Stack space={[3, 3, 3]}>
<Inline space={2}>
<Badge tone="primary">{readingTime.text}</Badge>
<Badge>{`${readingTime.words} words`}</Badge>
<TextInput
id={inputId}
onChange={handleChange}
value={readingTime.minutes || ""}
readOnly={readOnly}
placeholder={readingTime.minutes}
ref={ref}
/>
</Inline>
</Stack>
</Card>
</FormField>
);
});
export default withDocument(RenderReadingTime);S
Iāve updated the component to patch the resulting reading time into the schema (as well as showing a preview in the studio).
Although this works, Iām unsure if Iām following the best practice in terms of patching data! I ended up using the
The reading time component supports both
Here is the component:
Although this works, Iām unsure if Iām following the best practice in terms of patching data! I ended up using the
sanityClientrather than
PatchEventas I believe PatchEvent requires an onChange event to trigger, and as the component doesnāt have a direct user input, this isnāt possible (correct me if Iām wrong).
The reading time component supports both
stringand
blocktypes, and requires the options of
fieldName(which field that will be patched, which is probably is the same field) & `target`:
{
title: "Content",
name: "content",
type: "array",
of: [{ type: "block" }],
},
{
name: "reading",
title: "Reading Time",
type: "string",
inputComponent: RenderReadingTime,
options: { fieldName: "reading", target: "content" }
},import React, { useState, useMemo, useEffect } from "react";
import { Card, Stack, Inline, Badge } from "@sanity/ui";
import { FormField } from "@sanity/base/components";
import { withDocument } from "part:@sanity/form-builder";
import { useReadingTime } from "react-hook-reading-time";
import sanityClient from "part:@sanity/base/client";
const client = sanityClient.withConfig({ apiVersion: "2022-02-15" });
export const RenderReadingTime = React.forwardRef((props, ref) => {
const { type, document } = props;
const [time, setTime] = useState(null);
const valid = useMemo(() => {
const validate =
(type.options?.target !== undefined &&
document[type.options?.target] !== undefined &&
document[type.options?.fieldName] !== undefined &&
Array.isArray(document[type.options?.target])) ||
Object.prototype.toString.call(document[type.options?.target]) === "[object String]";
const targetType = validate
? Array.isArray(document[type.options?.target])
? "block"
: "string"
: false;
return targetType;
}, []);
useEffect(() => {
client
.patch(document._id)
.set({ reading: time?.words && valid !== false ? time.text : "" })
.commit()
.catch((err) => console.error("The update to 'reading' failed: ", err.message));
}, [time]);
return (
<FormField description={type.description} title={type.title}>
<Card padding={[3, 3, 3, 3]} shadow={1} radius={2} ref={ref}>
<Stack space={[3, 3, 3]}>
{valid !== false ? (
<RenderReadingTimePatch
document={document}
type={type}
valid={valid}
time={time}
setTime={setTime}
/>
) : (
<Inline space={2}>
<Badge tone={"critical"}>Invalid or missing target/fieldName</Badge>
</Inline>
)}
</Stack>
</Card>
</FormField>
);
});
const RenderReadingTimePatch = ({ document, type, valid, time, setTime }) => {
const debouncedInput = useDebounce(document[type.options.target], 1000);
useEffect(() => {
if (valid === "block") {
const filteredBlock = document[type.options.target]
.filter((x) => x._type === "block")
.map((children) => children.children.map((texts) => texts.text));
setTime(useReadingTime(filteredBlock.join(" ")));
} else {
setTime(useReadingTime(useReadingTime(document[type.options.target])));
}
}, [debouncedInput]);
return (
<div>
{time && (
<Inline space={2}>
<Badge tone={"primary"}>{time.text}</Badge>
<Badge>{`${time.words} words`}</Badge>
</Inline>
)}
</div>
);
};
const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};
export default withDocument(RenderReadingTime);N
user P
Great! Which setState value has to be changed to only return "minutes" as a number instead of complete string? Because I am need the minutes only this way I can apply my own JSX.S
You can change the section below from
time.textto
time.minutes.toString()
useEffect(() => {
client
.patch(document._id)
.set({ reading: time?.words && valid !== false ? time.text : "" }) //CHANGE HERE
.commit()
.catch((err) => console.error("The update to 'reading' failed: ", err.message));
}, [time]);S
So becomes
.set({ reading: time?.words && valid !== false ? time.text : "" }).set({ reading: time?.words && valid !== false ? time.minutes.toString() : "" })N
Applied time.minutes but missed the toString() function. Thank you Simon!
Sanity ā Build the way you think, not the way your CMS thinks
Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.