aboutsummaryrefslogtreecommitdiffstats
path: root/src/utils/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'src/utils/hooks')
-rw-r--r--src/utils/hooks/use-add-classname.tsx34
-rw-r--r--src/utils/hooks/use-attributes.tsx52
-rw-r--r--src/utils/hooks/use-breadcrumb.tsx107
-rw-r--r--src/utils/hooks/use-click-outside.tsx46
-rw-r--r--src/utils/hooks/use-data-from-api.tsx23
-rw-r--r--src/utils/hooks/use-github-api.tsx (renamed from src/utils/hooks/useGithubApi.tsx)15
-rw-r--r--src/utils/hooks/use-headings-tree.tsx (renamed from src/utils/hooks/useHeadingsTree.tsx)89
-rw-r--r--src/utils/hooks/use-input-autofocus.tsx39
-rw-r--r--src/utils/hooks/use-is-mounted.tsx19
-rw-r--r--src/utils/hooks/use-local-storage.tsx35
-rw-r--r--src/utils/hooks/use-pagination.tsx117
-rw-r--r--src/utils/hooks/use-prism.tsx182
-rw-r--r--src/utils/hooks/use-query-selector-all.tsx24
-rw-r--r--src/utils/hooks/use-reading-time.tsx58
-rw-r--r--src/utils/hooks/use-redirection.tsx33
-rw-r--r--src/utils/hooks/use-route-change.tsx12
-rw-r--r--src/utils/hooks/use-scroll-position.tsx15
-rw-r--r--src/utils/hooks/use-settings.tsx118
-rw-r--r--src/utils/hooks/use-styles.tsx29
-rw-r--r--src/utils/hooks/use-update-ackee-options.tsx19
20 files changed, 1042 insertions, 24 deletions
diff --git a/src/utils/hooks/use-add-classname.tsx b/src/utils/hooks/use-add-classname.tsx
new file mode 100644
index 0000000..0584084
--- /dev/null
+++ b/src/utils/hooks/use-add-classname.tsx
@@ -0,0 +1,34 @@
+import { useCallback, useEffect } from 'react';
+
+export type UseAddClassNameProps = {
+ className: string;
+ element?: HTMLElement;
+ elements?: NodeListOf<HTMLElement> | HTMLElement[];
+};
+
+/**
+ * Add className to the given element(s).
+ *
+ * @param {UseAddClassNameProps} props - An object with classnames and one or more elements.
+ */
+const useAddClassName = ({
+ className,
+ element,
+ elements,
+}: UseAddClassNameProps) => {
+ const classNames = className.split(' ').filter((string) => string !== '');
+
+ const setClassName = useCallback(
+ (el: HTMLElement) => {
+ el.classList.add(...classNames);
+ },
+ [classNames]
+ );
+
+ useEffect(() => {
+ if (element) setClassName(element);
+ if (elements && elements.length > 0) elements.forEach(setClassName);
+ }, [element, elements, setClassName]);
+};
+
+export default useAddClassName;
diff --git a/src/utils/hooks/use-attributes.tsx b/src/utils/hooks/use-attributes.tsx
new file mode 100644
index 0000000..6d18048
--- /dev/null
+++ b/src/utils/hooks/use-attributes.tsx
@@ -0,0 +1,52 @@
+import { fromKebabCaseToCamelCase } from '@utils/helpers/strings';
+import { useCallback, useEffect } from 'react';
+
+export type useAttributesProps = {
+ /**
+ * An HTML element.
+ */
+ element?: HTMLElement;
+ /**
+ * A node list of HTML Element.
+ */
+ elements?: NodeListOf<HTMLElement> | HTMLElement[];
+ /**
+ * The attribute name.
+ */
+ attribute: string;
+ /**
+ * The attribute value.
+ */
+ value: string;
+};
+
+/**
+ * Set HTML attributes to the given element or to the HTML document.
+ *
+ * @param props - An object with element, attribute name and value.
+ */
+const useAttributes = ({
+ element,
+ elements,
+ attribute,
+ value,
+}: useAttributesProps) => {
+ const setAttribute = useCallback(
+ (el: HTMLElement) => {
+ if (attribute.startsWith('data')) {
+ el.setAttribute(attribute, value);
+ } else {
+ const camelCaseAttribute = fromKebabCaseToCamelCase(attribute);
+ el.dataset[camelCaseAttribute] = value;
+ }
+ },
+ [attribute, value]
+ );
+
+ useEffect(() => {
+ if (element) setAttribute(element);
+ if (elements && elements.length > 0) elements.forEach(setAttribute);
+ }, [element, elements, setAttribute]);
+};
+
+export default useAttributes;
diff --git a/src/utils/hooks/use-breadcrumb.tsx b/src/utils/hooks/use-breadcrumb.tsx
new file mode 100644
index 0000000..130ebf1
--- /dev/null
+++ b/src/utils/hooks/use-breadcrumb.tsx
@@ -0,0 +1,107 @@
+import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import { slugify } from '@utils/helpers/strings';
+import { useIntl } from 'react-intl';
+import { BreadcrumbList } from 'schema-dts';
+import useSettings from './use-settings';
+
+export type useBreadcrumbProps = {
+ /**
+ * The current page title.
+ */
+ title: string;
+ /**
+ * The current page url.
+ */
+ url: string;
+};
+
+export type useBreadcrumbReturn = {
+ /**
+ * The breadcrumb items.
+ */
+ items: BreadcrumbItem[];
+ /**
+ * The breadcrumb JSON schema.
+ */
+ schema: BreadcrumbList['itemListElement'][];
+};
+
+/**
+ * Retrieve the breadcrumb items.
+ *
+ * @param {useBreadcrumbProps} props - An object (the current page title & url).
+ * @returns {useBreadcrumbReturn} The breadcrumb items and its JSON schema.
+ */
+const useBreadcrumb = ({
+ title,
+ url,
+}: useBreadcrumbProps): useBreadcrumbReturn => {
+ const intl = useIntl();
+ const { website } = useSettings();
+ const isArticle = url.startsWith('/article/');
+ const isHome = url === '/';
+ const isPageNumber = url.includes('/page/');
+ const isProject = url.startsWith('/projets/');
+ const isSearch = url.startsWith('/recherche');
+ const isThematic = url.startsWith('/thematique/');
+ const isTopic = url.startsWith('/sujet/');
+
+ const homeLabel = intl.formatMessage({
+ defaultMessage: 'Home',
+ description: 'Breadcrumb: home label',
+ id: 'j5k9Fe',
+ });
+ const items: BreadcrumbItem[] = [{ id: 'home', name: homeLabel, url: '/' }];
+ const schema: BreadcrumbList['itemListElement'][] = [
+ {
+ '@type': 'ListItem',
+ position: 1,
+ name: homeLabel,
+ item: website.url,
+ },
+ ];
+
+ if (isHome) return { items, schema };
+
+ if (isArticle || isPageNumber || isSearch || isThematic || isTopic) {
+ const blogLabel = intl.formatMessage({
+ defaultMessage: 'Blog',
+ description: 'Breadcrumb: blog label',
+ id: 'Es52wh',
+ });
+ items.push({ id: 'blog', name: blogLabel, url: '/blog' });
+ schema.push({
+ '@type': 'ListItem',
+ position: 2,
+ name: blogLabel,
+ item: `${website.url}/blog`,
+ });
+ }
+
+ if (isProject) {
+ const projectsLabel = intl.formatMessage({
+ defaultMessage: 'Projects',
+ description: 'Breadcrumb: projects label',
+ id: '28GZdv',
+ });
+ items.push({ id: 'blog', name: projectsLabel, url: '/projets' });
+ schema.push({
+ '@type': 'ListItem',
+ position: 2,
+ name: projectsLabel,
+ item: `${website.url}/projets`,
+ });
+ }
+
+ items.push({ id: slugify(title), name: title, url });
+ schema.push({
+ '@type': 'ListItem',
+ position: schema.length + 1,
+ name: title,
+ item: `${website.url}${url}`,
+ });
+
+ return { items, schema };
+};
+
+export default useBreadcrumb;
diff --git a/src/utils/hooks/use-click-outside.tsx b/src/utils/hooks/use-click-outside.tsx
new file mode 100644
index 0000000..cead98b
--- /dev/null
+++ b/src/utils/hooks/use-click-outside.tsx
@@ -0,0 +1,46 @@
+import { RefObject, useCallback, useEffect } from 'react';
+
+/**
+ * Listen for click/focus outside an element and execute the given callback.
+ *
+ * @param el - A React reference to an element.
+ * @param callback - A callback function to execute on click outside.
+ */
+const useClickOutside = (
+ el: RefObject<HTMLElement>,
+ callback: (target: EventTarget) => void
+) => {
+ /**
+ * Check if an event target is outside an element.
+ *
+ * @param {RefObject<HTMLElement>} ref - A React reference object.
+ * @param {EventTarget} target - An event target.
+ * @returns {boolean} True if the event target is outside the ref object.
+ */
+ const isTargetOutside = (
+ ref: RefObject<HTMLElement>,
+ target: EventTarget
+ ): boolean => {
+ if (!ref.current) return false;
+ return !ref.current.contains(target as Node);
+ };
+
+ const handleEvent = useCallback(
+ (e: MouseEvent | FocusEvent) => {
+ if (e.target && isTargetOutside(el, e.target)) callback(e.target);
+ },
+ [el, callback]
+ );
+
+ useEffect(() => {
+ document.addEventListener('mousedown', handleEvent);
+ document.addEventListener('focusin', handleEvent);
+
+ return () => {
+ document.removeEventListener('mousedown', handleEvent);
+ document.removeEventListener('focusin', handleEvent);
+ };
+ }, [handleEvent]);
+};
+
+export default useClickOutside;
diff --git a/src/utils/hooks/use-data-from-api.tsx b/src/utils/hooks/use-data-from-api.tsx
new file mode 100644
index 0000000..7082941
--- /dev/null
+++ b/src/utils/hooks/use-data-from-api.tsx
@@ -0,0 +1,23 @@
+import { useEffect, useState } from 'react';
+
+/**
+ * Fetch data from an API.
+ *
+ * This hook is a wrapper to `setState` + `useEffect`.
+ *
+ * @param fetcher - A function to fetch data from API.
+ * @returns {T | undefined} The requested data.
+ */
+const useDataFromAPI = <T extends unknown>(
+ fetcher: () => Promise<T>
+): T | undefined => {
+ const [data, setData] = useState<T>();
+
+ useEffect(() => {
+ fetcher().then((apiData) => setData(apiData));
+ }, [fetcher]);
+
+ return data;
+};
+
+export default useDataFromAPI;
diff --git a/src/utils/hooks/useGithubApi.tsx b/src/utils/hooks/use-github-api.tsx
index 4b0b3b2..edff974 100644
--- a/src/utils/hooks/useGithubApi.tsx
+++ b/src/utils/hooks/use-github-api.tsx
@@ -1,15 +1,22 @@
-import { RepoData } from '@ts/types/repos';
+import { SWRResult } from '@ts/types/swr';
import useSWR, { Fetcher } from 'swr';
+export type RepoData = {
+ created_at: string;
+ updated_at: string;
+ stargazers_count: number;
+};
+
const fetcher: Fetcher<RepoData, string> = (...args) =>
fetch(...args).then((res) => res.json());
/**
* Retrieve data from Github API.
- * @param repo The repo name. Format: "User/project-slug".
- * @returns {object} The data and two booleans to determine if is loading/error.
+ *
+ * @param repo - The Github repo (`owner/repo-name`).
+ * @returns The repository data.
*/
-const useGithubApi = (repo: string) => {
+const useGithubApi = (repo: string): SWRResult<RepoData> => {
const apiUrl = repo ? `https://api.github.com/repos/${repo}` : null;
const { data, error } = useSWR<RepoData>(apiUrl, fetcher);
diff --git a/src/utils/hooks/useHeadingsTree.tsx b/src/utils/hooks/use-headings-tree.tsx
index f2be406..4646b4a 100644
--- a/src/utils/hooks/useHeadingsTree.tsx
+++ b/src/utils/hooks/use-headings-tree.tsx
@@ -1,40 +1,71 @@
-import { Heading } from '@ts/types/app';
-import { slugify } from '@utils/helpers/slugify';
-import { useRouter } from 'next/router';
+import { slugify } from '@utils/helpers/strings';
import { useCallback, useEffect, useMemo, useState } from 'react';
-const useHeadingsTree = (wrapper: string) => {
- const router = useRouter();
+export type Heading = {
+ /**
+ * The heading depth.
+ */
+ depth: number;
+ /**
+ * The heading id.
+ */
+ id: string;
+ /**
+ * The heading children.
+ */
+ children: Heading[];
+ /**
+ * The heading title.
+ */
+ title: string;
+};
+
+/**
+ * Get the headings tree of the given HTML element.
+ *
+ * @param {HTMLElement} wrapper - An HTML element that contains the headings.
+ * @returns {Heading[]} The headings tree.
+ */
+const useHeadingsTree = (wrapper: HTMLElement): Heading[] => {
const depths = useMemo(() => ['h2', 'h3', 'h4', 'h5', 'h6'], []);
const [allHeadings, setAllHeadings] =
useState<NodeListOf<HTMLHeadingElement>>();
+ const [headingsTree, setHeadingsTree] = useState<Heading[]>([]);
useEffect(() => {
- const query = depths
- .map((depth) => `${wrapper} > *:not(aside, #comments) ${depth}`)
- .join(', ');
+ const query = depths.join(', ');
const result: NodeListOf<HTMLHeadingElement> =
- document.querySelectorAll(query);
+ wrapper.querySelectorAll(query);
setAllHeadings(result);
- }, [depths, wrapper, router.asPath]);
-
- const [headingsTree, setHeadingsTree] = useState<Heading[]>([]);
-
- const getElementDepth = useCallback(
- (el: HTMLHeadingElement) => {
+ }, [depths, wrapper]);
+
+ const getDepth = useCallback(
+ /**
+ * Retrieve the heading element depth.
+ *
+ * @param {HTMLHeadingElement} el - An heading element.
+ * @returns {number} The heading depth.
+ */
+ (el: HTMLHeadingElement): number => {
return depths.findIndex((depth) => depth === el.localName);
},
[depths]
);
const formatHeadings = useCallback(
+ /**
+ * Convert a list of headings into an array of Heading objects.
+ *
+ * @param {NodeListOf<HTMLHeadingElement>} headings - A list of headings.
+ * @returns {Heading[]} An array of Heading objects.
+ */
(headings: NodeListOf<HTMLHeadingElement>): Heading[] => {
const formattedHeadings: Heading[] = [];
Array.from(headings).forEach((heading) => {
const title: string = heading.textContent!;
const id = slugify(title);
- const depth = getElementDepth(heading);
+ const depth = getDepth(heading);
const children: Heading[] = [];
heading.id = id;
@@ -49,10 +80,16 @@ const useHeadingsTree = (wrapper: string) => {
return formattedHeadings;
},
- [getElementDepth]
+ [getDepth]
);
const buildSubTree = useCallback(
+ /**
+ * Build the heading subtree.
+ *
+ * @param {Heading} parent - The heading parent.
+ * @param {Heading} currentHeading - The current heading element.
+ */
(parent: Heading, currentHeading: Heading): void => {
if (parent.depth === currentHeading.depth - 1) {
parent.children.push(currentHeading);
@@ -65,6 +102,12 @@ const useHeadingsTree = (wrapper: string) => {
);
const buildTree = useCallback(
+ /**
+ * Build a heading tree.
+ *
+ * @param {Heading[]} headings - An array of Heading objects.
+ * @returns {Heading[]} The headings tree.
+ */
(headings: Heading[]): Heading[] => {
const tree: Heading[] = [];
@@ -82,7 +125,13 @@ const useHeadingsTree = (wrapper: string) => {
[buildSubTree]
);
- const getHeadingsList = useCallback(
+ const getHeadingsTree = useCallback(
+ /**
+ * Retrieve a headings tree from a list of headings element.
+ *
+ * @param {NodeListOf<HTMLHeadingElement>} headings - A headings list.
+ * @returns {Heading[]} The headings tree.
+ */
(headings: NodeListOf<HTMLHeadingElement>): Heading[] => {
const formattedHeadings = formatHeadings(headings);
@@ -93,10 +142,10 @@ const useHeadingsTree = (wrapper: string) => {
useEffect(() => {
if (allHeadings) {
- const headingsList = getHeadingsList(allHeadings);
+ const headingsList = getHeadingsTree(allHeadings);
setHeadingsTree(headingsList);
}
- }, [allHeadings, getHeadingsList]);
+ }, [allHeadings, getHeadingsTree]);
return headingsTree;
};
diff --git a/src/utils/hooks/use-input-autofocus.tsx b/src/utils/hooks/use-input-autofocus.tsx
new file mode 100644
index 0000000..c7700e9
--- /dev/null
+++ b/src/utils/hooks/use-input-autofocus.tsx
@@ -0,0 +1,39 @@
+import { RefObject, useEffect } from 'react';
+
+export type UseInputAutofocusProps = {
+ /**
+ * The focus condition. True give focus to the input.
+ */
+ condition: boolean;
+ /**
+ * An optional delay. Default: 0.
+ */
+ delay?: number;
+ /**
+ * A reference to the input element.
+ */
+ ref: RefObject<HTMLInputElement>;
+};
+
+/**
+ * Set focus on an input with an optional delay.
+ */
+const useInputAutofocus = ({
+ condition,
+ delay = 0,
+ ref,
+}: UseInputAutofocusProps) => {
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ if (ref.current && condition) {
+ ref.current.focus();
+ }
+ }, delay);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [condition, delay, ref]);
+};
+
+export default useInputAutofocus;
diff --git a/src/utils/hooks/use-is-mounted.tsx b/src/utils/hooks/use-is-mounted.tsx
new file mode 100644
index 0000000..ca79afb
--- /dev/null
+++ b/src/utils/hooks/use-is-mounted.tsx
@@ -0,0 +1,19 @@
+import { RefObject, useEffect, useState } from 'react';
+
+/**
+ * Check if an HTML element is mounted.
+ *
+ * @param {RefObject<HTMLElement>} ref - A React reference to an HTML element.
+ * @returns {boolean} True if the HTML element is mounted.
+ */
+const useIsMounted = (ref: RefObject<HTMLElement>) => {
+ const [isMounted, setIsMounted] = useState<boolean>(false);
+
+ useEffect(() => {
+ if (ref.current) setIsMounted(true);
+ }, [ref]);
+
+ return isMounted;
+};
+
+export default useIsMounted;
diff --git a/src/utils/hooks/use-local-storage.tsx b/src/utils/hooks/use-local-storage.tsx
new file mode 100644
index 0000000..da0292b
--- /dev/null
+++ b/src/utils/hooks/use-local-storage.tsx
@@ -0,0 +1,35 @@
+import { LocalStorage } from '@services/local-storage';
+import { Dispatch, SetStateAction, useEffect, useState } from 'react';
+
+export type UseLocalStorageReturn<T> = {
+ value: T;
+ setValue: Dispatch<SetStateAction<T>>;
+};
+
+/**
+ * Use the local storage.
+ *
+ * @param {string} key - The storage local key.
+ * @param {T} [fallbackValue] - A fallback value if local storage is empty.
+ * @returns {UseLocalStorageReturn<T>} An object with value and setValue.
+ */
+const useLocalStorage = <T extends unknown>(
+ key: string,
+ fallbackValue: T
+): UseLocalStorageReturn<T> => {
+ const getInitialValue = () => {
+ if (typeof window === 'undefined') return fallbackValue;
+ const storedValue = LocalStorage.get<T>(key);
+ return storedValue || fallbackValue;
+ };
+
+ const [value, setValue] = useState<T>(getInitialValue);
+
+ useEffect(() => {
+ LocalStorage.set(key, value);
+ }, [key, value]);
+
+ return { value, setValue };
+};
+
+export default useLocalStorage;
diff --git a/src/utils/hooks/use-pagination.tsx b/src/utils/hooks/use-pagination.tsx
new file mode 100644
index 0000000..a80a539
--- /dev/null
+++ b/src/utils/hooks/use-pagination.tsx
@@ -0,0 +1,117 @@
+import { type EdgesResponse, type EdgesVars } from '@services/graphql/api';
+import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite';
+
+export type UsePaginationProps<T> = {
+ /**
+ * The initial data.
+ */
+ fallbackData: EdgesResponse<T>[];
+ /**
+ * A function to fetch more data.
+ */
+ fetcher: (props: EdgesVars) => Promise<EdgesResponse<T>>;
+ /**
+ * The number of results per page.
+ */
+ perPage: number;
+ /**
+ * An optional search string.
+ */
+ search?: string;
+};
+
+export type UsePaginationReturn<T> = {
+ /**
+ * The data from the API.
+ */
+ data?: EdgesResponse<T>[];
+ /**
+ * An error thrown by fetcher.
+ */
+ error: any;
+ /**
+ * Determine if there's more data to fetch.
+ */
+ hasNextPage?: boolean;
+ /**
+ * Determine if the initial data is loading.
+ */
+ isLoadingInitialData: boolean;
+ /**
+ * Determine if more data is currently loading.
+ */
+ isLoadingMore?: boolean;
+ /**
+ * Determine if the data is refreshing.
+ */
+ isRefreshing?: boolean;
+ /**
+ * Determine if there's a request or revalidation loading.
+ */
+ isValidating: boolean;
+ /**
+ * Set the number of pages that need to be fetched.
+ */
+ setSize: (
+ size: number | ((_size: number) => number)
+ ) => Promise<EdgesResponse<T>[] | undefined>;
+};
+
+/**
+ * Handle data fetching with pagination.
+ *
+ * This hook is a wrapper of `useSWRInfinite` hook.
+ *
+ * @param {UsePaginationProps} props - The pagination configuration.
+ * @returns {UsePaginationReturn} An object with pagination data and helpers.
+ */
+const usePagination = <T extends object>({
+ fallbackData,
+ fetcher,
+ perPage,
+ search,
+}: UsePaginationProps<T>): UsePaginationReturn<T> => {
+ const getKey: SWRInfiniteKeyLoader = (
+ pageIndex: number,
+ previousData: EdgesResponse<T>
+ ): EdgesVars | null => {
+ // Reached the end.
+ if (previousData && !previousData.edges.length) return null;
+
+ // Fetch data using this parameters.
+ return pageIndex === 0
+ ? { first: perPage, search }
+ : {
+ first: perPage,
+ after: previousData.pageInfo.endCursor,
+ search,
+ };
+ };
+
+ const { data, error, isValidating, size, setSize } = useSWRInfinite(
+ getKey,
+ fetcher,
+ { fallbackData }
+ );
+
+ const isLoadingInitialData = !data && !error;
+ const isLoadingMore =
+ isLoadingInitialData ||
+ (size > 0 && data && typeof data[size - 1] === 'undefined');
+ const isRefreshing = isValidating && data && data.length === size;
+ const hasNextPage =
+ data && data.length > 0 && data[data.length - 1].pageInfo.hasNextPage;
+
+ return {
+ data,
+ error,
+ hasNextPage,
+ isLoadingInitialData,
+ isLoadingMore,
+ isRefreshing,
+ isValidating,
+ setSize,
+ };
+};
+
+export default usePagination;
diff --git a/src/utils/hooks/use-prism.tsx b/src/utils/hooks/use-prism.tsx
new file mode 100644
index 0000000..ef1a4c8
--- /dev/null
+++ b/src/utils/hooks/use-prism.tsx
@@ -0,0 +1,182 @@
+import Prism from 'prismjs';
+import { useEffect, useMemo } from 'react';
+import { useIntl } from 'react-intl';
+
+const PRISM_PLUGINS = [
+ 'autoloader',
+ 'color-scheme',
+ 'command-line',
+ 'copy-to-clipboard',
+ 'diff-highlight',
+ 'inline-color',
+ 'line-highlight',
+ 'line-numbers',
+ 'match-braces',
+ 'normalize-whitespace',
+ 'show-language',
+ 'toolbar',
+] as const;
+
+export type PrismPlugin = typeof PRISM_PLUGINS[number];
+
+export type DefaultPrismPlugin = Extract<
+ PrismPlugin,
+ | 'autoloader'
+ | 'color-scheme'
+ | 'copy-to-clipboard'
+ | 'match-braces'
+ | 'normalize-whitespace'
+ | 'show-language'
+ | 'toolbar'
+>;
+
+export type OptionalPrismPlugin = Exclude<PrismPlugin, DefaultPrismPlugin>;
+
+export type PrismLanguage =
+ | 'apacheconf'
+ | 'bash'
+ | 'css'
+ | 'diff'
+ | 'docker'
+ | 'editorconfig'
+ | 'ejs'
+ | 'git'
+ | 'graphql'
+ | 'html'
+ | 'ignore'
+ | 'ini'
+ | 'javascript'
+ | 'jsdoc'
+ | 'json'
+ | 'jsx'
+ | 'makefile'
+ | 'markup'
+ | 'php'
+ | 'phpdoc'
+ | 'regex'
+ | 'scss'
+ | 'shell-session'
+ | 'smarty'
+ | 'tcl'
+ | 'toml'
+ | 'tsx'
+ | 'twig'
+ | 'yaml';
+
+export type PrismAttributes = {
+ 'data-prismjs-copy': string;
+ 'data-prismjs-copy-success': string;
+ 'data-prismjs-copy-error': string;
+ 'data-prismjs-color-scheme-dark': string;
+ 'data-prismjs-color-scheme-light': string;
+};
+
+export type UsePrismProps = {
+ language?: PrismLanguage;
+ plugins: OptionalPrismPlugin[];
+};
+
+export type UsePrismReturn = {
+ attributes: PrismAttributes;
+ className: string;
+};
+
+/**
+ * Import and configure all given Prism plugins.
+ *
+ * @param {PrismPlugin[]} plugins - The Prism plugins to activate.
+ */
+const loadPrismPlugins = async (plugins: PrismPlugin[]) => {
+ for (const plugin of plugins) {
+ try {
+ if (plugin === 'color-scheme') {
+ await import(`@utils/plugins/prism-${plugin}`);
+ } else {
+ await import(`prismjs/plugins/${plugin}/prism-${plugin}.min.js`);
+ }
+
+ if (plugin === 'autoloader') {
+ Prism.plugins.autoloader.languages_path = '/prism/';
+ }
+ } catch (error) {
+ console.error('usePrism: an error occurred while loading Prism plugins.');
+ console.error(error);
+ }
+ }
+};
+
+/**
+ * Use Prism and its plugins.
+ *
+ * @param {UsePrismProps} props - An object of options.
+ * @returns {UsePrismReturn} An object of data.
+ */
+const usePrism = ({ language, plugins }: UsePrismProps): UsePrismReturn => {
+ /**
+ * The order matter. Toolbar must be loaded before some other plugins.
+ */
+ const defaultPlugins: DefaultPrismPlugin[] = useMemo(
+ () => [
+ 'toolbar',
+ 'autoloader',
+ 'show-language',
+ 'copy-to-clipboard',
+ 'color-scheme',
+ 'match-braces',
+ 'normalize-whitespace',
+ ],
+ []
+ );
+
+ useEffect(() => {
+ loadPrismPlugins([...defaultPlugins, ...plugins]).then(() => {
+ Prism.highlightAll();
+ });
+ }, [defaultPlugins, plugins]);
+
+ const defaultClassName = 'match-braces';
+ const languageClassName = language ? `language-${language}` : '';
+ const pluginsClassName = plugins.join(' ');
+ const className = `${defaultClassName} ${pluginsClassName} ${languageClassName}`;
+
+ const intl = useIntl();
+ const copyText = intl.formatMessage({
+ defaultMessage: 'Copy',
+ description: 'usePrism: copy button text (not clicked)',
+ id: '6GySNl',
+ });
+ const copiedText = intl.formatMessage({
+ defaultMessage: 'Copied!',
+ description: 'usePrism: copy button text (clicked)',
+ id: 'nsw6Th',
+ });
+ const errorText = intl.formatMessage({
+ defaultMessage: 'Use Ctrl+c to copy',
+ description: 'usePrism: copy button error text',
+ id: 'lKhTGM',
+ });
+ const darkTheme = intl.formatMessage({
+ defaultMessage: 'Dark Theme 🌙',
+ description: 'usePrism: toggle dark theme button text',
+ id: 'QLisK6',
+ });
+ const lightTheme = intl.formatMessage({
+ defaultMessage: 'Light Theme 🌞',
+ description: 'usePrism: toggle light theme button text',
+ id: 'hHVgW3',
+ });
+ const attributes = {
+ 'data-prismjs-copy': copyText,
+ 'data-prismjs-copy-success': copiedText,
+ 'data-prismjs-copy-error': errorText,
+ 'data-prismjs-color-scheme-dark': darkTheme,
+ 'data-prismjs-color-scheme-light': lightTheme,
+ };
+
+ return {
+ attributes,
+ className,
+ };
+};
+
+export default usePrism;
diff --git a/src/utils/hooks/use-query-selector-all.tsx b/src/utils/hooks/use-query-selector-all.tsx
new file mode 100644
index 0000000..6ac8a08
--- /dev/null
+++ b/src/utils/hooks/use-query-selector-all.tsx
@@ -0,0 +1,24 @@
+import { useRouter } from 'next/router';
+import { useEffect, useState } from 'react';
+
+/**
+ * Use `document.querySelectorAll`.
+ *
+ * @param {string} query - A query.
+ * @returns {NodeListOf<HTMLElementTagNameMap[T]|undefined>} - The node list.
+ */
+const useQuerySelectorAll = <T extends keyof HTMLElementTagNameMap>(
+ query: string
+): NodeListOf<HTMLElementTagNameMap[T]> | undefined => {
+ const [elements, setElements] =
+ useState<NodeListOf<HTMLElementTagNameMap[T]>>();
+ const { asPath } = useRouter();
+
+ useEffect(() => {
+ setElements(document.querySelectorAll(query));
+ }, [asPath, query]);
+
+ return elements;
+};
+
+export default useQuerySelectorAll;
diff --git a/src/utils/hooks/use-reading-time.tsx b/src/utils/hooks/use-reading-time.tsx
new file mode 100644
index 0000000..fb54135
--- /dev/null
+++ b/src/utils/hooks/use-reading-time.tsx
@@ -0,0 +1,58 @@
+import { useIntl } from 'react-intl';
+
+/**
+ * Retrieve the estimated reading time by words count.
+ *
+ * @param {number} wordsCount - The number of words.
+ * @returns {string} The estimated reading time.
+ */
+const useReadingTime = (
+ wordsCount: number,
+ onlyMinutes: boolean = false
+): string => {
+ const intl = useIntl();
+ const wordsPerMinute = 245;
+ const wordsPerSecond = wordsPerMinute / 60;
+ const estimatedTimeInSeconds = wordsCount / wordsPerSecond;
+
+ if (onlyMinutes) {
+ const estimatedTimeInMinutes = Math.round(estimatedTimeInSeconds / 60);
+
+ return intl.formatMessage(
+ {
+ defaultMessage: '{minutesCount} minutes',
+ description: 'useReadingTime: rounded minutes count',
+ id: 's1i43J',
+ },
+ { minutesCount: estimatedTimeInMinutes }
+ );
+ } else {
+ const estimatedTimeInMinutes = Math.floor(estimatedTimeInSeconds / 60);
+
+ if (estimatedTimeInMinutes <= 0) {
+ return intl.formatMessage(
+ {
+ defaultMessage: '{count} seconds',
+ description: 'useReadingTime: seconds count',
+ id: 'i7Wq3G',
+ },
+ { count: estimatedTimeInSeconds.toFixed(0) }
+ );
+ }
+
+ const remainingSeconds = Math.round(
+ estimatedTimeInSeconds - estimatedTimeInMinutes * 60
+ ).toFixed(0);
+
+ return intl.formatMessage(
+ {
+ defaultMessage: '{minutesCount} minutes {secondsCount} seconds',
+ description: 'useReadingTime: minutes + seconds count',
+ id: 'OevMeU',
+ },
+ { minutesCount: estimatedTimeInMinutes, secondsCount: remainingSeconds }
+ );
+ }
+};
+
+export default useReadingTime;
diff --git a/src/utils/hooks/use-redirection.tsx b/src/utils/hooks/use-redirection.tsx
new file mode 100644
index 0000000..9eb26c2
--- /dev/null
+++ b/src/utils/hooks/use-redirection.tsx
@@ -0,0 +1,33 @@
+import { useRouter } from 'next/router';
+import { useEffect } from 'react';
+
+export type RouterQuery = {
+ param: string;
+ value: string;
+};
+
+export type UseRedirectionProps = {
+ /**
+ * The router query.
+ */
+ query: RouterQuery;
+ /**
+ * The redirection url.
+ */
+ redirectTo: string;
+};
+
+/**
+ * Redirect to another url when router query match the given parameters.
+ *
+ * @param {UseRedirectionProps} props - The redirection parameters.
+ */
+const useRedirection = ({ query, redirectTo }: UseRedirectionProps) => {
+ const router = useRouter();
+
+ useEffect(() => {
+ if (router.query[query.param] === query.value) router.push(redirectTo);
+ }, [query, redirectTo, router]);
+};
+
+export default useRedirection;
diff --git a/src/utils/hooks/use-route-change.tsx b/src/utils/hooks/use-route-change.tsx
new file mode 100644
index 0000000..82e01a1
--- /dev/null
+++ b/src/utils/hooks/use-route-change.tsx
@@ -0,0 +1,12 @@
+import { useRouter } from 'next/router';
+import { useEffect } from 'react';
+
+const useRouteChange = (callback: () => void) => {
+ const { events } = useRouter();
+
+ useEffect(() => {
+ events.on('routeChangeStart', callback);
+ }, [events, callback]);
+};
+
+export default useRouteChange;
diff --git a/src/utils/hooks/use-scroll-position.tsx b/src/utils/hooks/use-scroll-position.tsx
new file mode 100644
index 0000000..47cfdd0
--- /dev/null
+++ b/src/utils/hooks/use-scroll-position.tsx
@@ -0,0 +1,15 @@
+import { useEffect } from 'react';
+
+/**
+ * Execute the given function based on scroll position.
+ *
+ * @param scrollHandler - A callback function.
+ */
+const useScrollPosition = (scrollHandler: () => void) => {
+ useEffect(() => {
+ window.addEventListener('scroll', scrollHandler);
+ return () => window.removeEventListener('scroll', scrollHandler);
+ }, [scrollHandler]);
+};
+
+export default useScrollPosition;
diff --git a/src/utils/hooks/use-settings.tsx b/src/utils/hooks/use-settings.tsx
new file mode 100644
index 0000000..cc5261b
--- /dev/null
+++ b/src/utils/hooks/use-settings.tsx
@@ -0,0 +1,118 @@
+import photo from '@assets/images/armand-philippot.jpg';
+import { settings } from '@utils/config';
+import { useRouter } from 'next/router';
+
+export type BlogSettings = {
+ /**
+ * The number of posts per page.
+ */
+ postsPerPage: number;
+};
+
+export type CopyrightSettings = {
+ /**
+ * The copyright end year.
+ */
+ end: string;
+ /**
+ * The copyright start year.
+ */
+ start: string;
+};
+
+export type LocaleSettings = {
+ /**
+ * The default locale.
+ */
+ default: string;
+ /**
+ * The supported locales.
+ */
+ supported: string[];
+};
+
+export type PictureSettings = {
+ /**
+ * The picture height.
+ */
+ height: number;
+ /**
+ * The picture url.
+ */
+ src: string;
+ /**
+ * The picture width.
+ */
+ width: number;
+};
+
+export type WebsiteSettings = {
+ /**
+ * The website name.
+ */
+ name: string;
+ /**
+ * The website baseline.
+ */
+ baseline: string;
+ /**
+ * The website copyright dates.
+ */
+ copyright: CopyrightSettings;
+ /**
+ * The website admin email.
+ */
+ email: string;
+ /**
+ * The website locales.
+ */
+ locales: LocaleSettings;
+ /**
+ * A picture representing the website.
+ */
+ picture: PictureSettings;
+ /**
+ * The website url.
+ */
+ url: string;
+};
+
+export type UseSettingsReturn = {
+ blog: BlogSettings;
+ website: WebsiteSettings;
+};
+
+/**
+ * Retrieve the website and blog settings.
+ *
+ * @returns {UseSettingsReturn} - An object describing settings.
+ */
+const useSettings = (): UseSettingsReturn => {
+ const { baseline, copyright, email, locales, name, postsPerPage, url } =
+ settings;
+ const router = useRouter();
+ const locale = router.locale || locales.defaultLocale;
+
+ return {
+ blog: {
+ postsPerPage,
+ },
+ website: {
+ baseline: locale.startsWith('en') ? baseline.en : baseline.fr,
+ copyright: {
+ end: copyright.endYear,
+ start: copyright.startYear,
+ },
+ email,
+ locales: {
+ default: locales.defaultLocale,
+ supported: locales.supported,
+ },
+ name,
+ picture: photo,
+ url,
+ },
+ };
+};
+
+export default useSettings;
diff --git a/src/utils/hooks/use-styles.tsx b/src/utils/hooks/use-styles.tsx
new file mode 100644
index 0000000..d47e9fb
--- /dev/null
+++ b/src/utils/hooks/use-styles.tsx
@@ -0,0 +1,29 @@
+import { RefObject, useEffect } from 'react';
+
+export type UseStylesProps = {
+ /**
+ * A property name or a CSS variable.
+ */
+ property: string;
+ /**
+ * The styles.
+ */
+ styles: string;
+ /**
+ * A targeted element reference.
+ */
+ target: RefObject<HTMLElement>;
+};
+
+/**
+ * Add styles to an element using a React reference.
+ *
+ * @param {UseStylesProps} props - An object with property, styles and target.
+ */
+const useStyles = ({ property, styles, target }: UseStylesProps) => {
+ useEffect(() => {
+ if (target.current) target.current.style.setProperty(property, styles);
+ }, [property, styles, target]);
+};
+
+export default useStyles;
diff --git a/src/utils/hooks/use-update-ackee-options.tsx b/src/utils/hooks/use-update-ackee-options.tsx
new file mode 100644
index 0000000..7c1d98a
--- /dev/null
+++ b/src/utils/hooks/use-update-ackee-options.tsx
@@ -0,0 +1,19 @@
+import { useAckeeTracker } from '@utils/providers/ackee';
+import { useEffect } from 'react';
+
+export type AckeeOptions = 'full' | 'partial';
+
+/**
+ * Update Ackee settings with the given choice.
+ *
+ * @param {AckeeOptions} value - Either `full` or `partial`.
+ */
+const useUpdateAckeeOptions = (value: AckeeOptions) => {
+ const { setDetailed } = useAckeeTracker();
+
+ useEffect(() => {
+ setDetailed(value === 'full');
+ }, [value, setDetailed]);
+};
+
+export default useUpdateAckeeOptions;