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?
Apr 4, 2022, 2:17 PM
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/
Apr 4, 2022, 2:41 PM
S
{ name: "read", title: "read", type: "number", description: "put number in minutes for reading time👇", validation: (Rule) => Rule.required(), },
Apr 4, 2022, 2:42 PM
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);
Apr 4, 2022, 3:44 PM
N
Tried doing it dynamically why over complicate things. Thanks!
Apr 4, 2022, 3:45 PM
S
You’re welcome!
Apr 4, 2022, 4:02 PM
N
user P
Got it working. but inside GROQ I get null if "reading" is added to the query.Apr 5, 2022, 8:28 AM
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, }`;
Apr 5, 2022, 8:28 AM
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, }`;
Apr 5, 2022, 9:41 AM
N
Returns null
Apr 5, 2022, 9:44 AM
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!
Apr 5, 2022, 9:48 AM
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 sideApr 5, 2022, 9:57 AM
N
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);
Apr 5, 2022, 10:13 AM
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);
Apr 5, 2022, 1:21 PM
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.Apr 5, 2022, 1:52 PM
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]);
Apr 5, 2022, 1:57 PM
S
So becomes
.set({ reading: time?.words && valid !== false ? time.text : "" })
.set({ reading: time?.words && valid !== false ? time.minutes.toString() : "" })
Apr 5, 2022, 1:58 PM
N
Applied time.minutes but missed the toString() function. Thank you Simon!
Apr 5, 2022, 1:59 PM
Sanity– build remarkable experiences at scale
Sanity is a modern headless CMS that treats content as data to power your digital business. Free to get started, and pay-as-you-go on all plans.