diff options
Diffstat (limited to 'src')
18 files changed, 312 insertions, 190 deletions
| diff --git a/src/components/atoms/images/icons/svg-paths/icons-paths/arrow-icon-paths.tsx b/src/components/atoms/images/icons/svg-paths/icons-paths/arrow-icon-paths.tsx index ee29d5d..0dc701a 100644 --- a/src/components/atoms/images/icons/svg-paths/icons-paths/arrow-icon-paths.tsx +++ b/src/components/atoms/images/icons/svg-paths/icons-paths/arrow-icon-paths.tsx @@ -1,7 +1,8 @@  /* eslint-disable react/jsx-no-literals */  import type { FC } from 'react'; +import type { Position } from '../../../../../types'; -export type ArrowOrientation = 'top' | 'right' | 'bottom' | 'left'; +export type ArrowOrientation = Exclude<Position, 'center'>;  const getArrowBarPathFrom = (orientation: ArrowOrientation) => {    switch (orientation) { diff --git a/src/components/atoms/loaders/spinner.module.scss b/src/components/atoms/loaders/spinner.module.scss deleted file mode 100644 index 3e05cb3..0000000 --- a/src/components/atoms/loaders/spinner.module.scss +++ /dev/null @@ -1,48 +0,0 @@ -@use "../../../styles/abstracts/functions" as fun; - -.wrapper { -  display: flex; -  flex-flow: row wrap; -  align-items: center; -  justify-content: center; -  gap: var(--spacing-2xs); -  margin: var(--spacing-md) 0; -} - -.ball { -  width: fun.convert-px(8); -  height: fun.convert-px(8); -  background: linear-gradient( -    to right, -    var(--color-primary-light) 0%, -    var(--color-primary-lighter) 100% -  ); -  border-radius: 50%; -  animation: spinner 1.4s infinite ease-in-out both; - -  &:first-child { -    animation-delay: -0.32s; -  } - -  &:nth-child(2) { -    animation-delay: -0.16s; -  } -} - -.text { -  margin-left: var(--spacing-xs); -  color: var(--color-primary-darker); -  text-align: center; -} - -@keyframes spinner { -  0%, -  80%, -  100% { -    transform: scale(0); -  } - -  40% { -    transform: scale(1); -  } -} diff --git a/src/components/atoms/loaders/spinner.test.tsx b/src/components/atoms/loaders/spinner.test.tsx deleted file mode 100644 index 553c3ef..0000000 --- a/src/components/atoms/loaders/spinner.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; -import { Spinner } from './spinner'; - -describe('Spinner', () => { -  it('renders a spinner loader', () => { -    render(<Spinner />); -    expect(screen.getByText('Loading...')).toBeInTheDocument(); -  }); - -  it('renders a spinner loader with a custom message', () => { -    render(<Spinner message="Submitting" />); -    expect(screen.getByText('Submitting')).toBeInTheDocument(); -  }); -}); diff --git a/src/components/atoms/loaders/spinner.tsx b/src/components/atoms/loaders/spinner.tsx deleted file mode 100644 index 968290b..0000000 --- a/src/components/atoms/loaders/spinner.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { FC } from 'react'; -import { useIntl } from 'react-intl'; -import styles from './spinner.module.scss'; - -export type SpinnerProps = { -  /** -   * The loading message. Default: "Loading...". -   */ -  message?: string; -}; - -/** - * Spinner component - * - * Render a loading message with animation. - */ -export const Spinner: FC<SpinnerProps> = ({ message }) => { -  const intl = useIntl(); - -  return ( -    <div className={styles.wrapper}> -      <div className={styles.ball}></div> -      <div className={styles.ball}></div> -      <div className={styles.ball}></div> -      <div className={styles.text}> -        {message ?? -          intl.formatMessage({ -            defaultMessage: 'Loading...', -            description: 'Spinner: loading text', -            id: 'q9cJQe', -          })} -      </div> -    </div> -  ); -}; diff --git a/src/components/atoms/loaders/spinner/index.ts b/src/components/atoms/loaders/spinner/index.ts new file mode 100644 index 0000000..cd17217 --- /dev/null +++ b/src/components/atoms/loaders/spinner/index.ts @@ -0,0 +1 @@ +export * from './spinner'; diff --git a/src/components/atoms/loaders/spinner/spinner.module.scss b/src/components/atoms/loaders/spinner/spinner.module.scss new file mode 100644 index 0000000..97882a4 --- /dev/null +++ b/src/components/atoms/loaders/spinner/spinner.module.scss @@ -0,0 +1,69 @@ +@use "../../../../styles/abstracts/functions" as fun; + +.wrapper { +  display: flex; +  align-items: center; +  gap: var(--spacing-xs); +  width: fit-content; + +  &--left { +    flex-flow: row-reverse wrap; +  } + +  &--right { +    flex-flow: row wrap; +  } + +  &--bottom { +    flex-flow: column nowrap; +  } + +  &--top { +    flex-flow: column-reverse nowrap; +  } +} + +.icon { +  --ball-size: #{fun.convert-px(8)}; + +  display: flex; +  flex-flow: row nowrap; +  justify-content: space-between; +  width: calc((var(--ball-size) * 3) + var(--spacing-xs)); + +  &__ball { +    width: var(--ball-size); +    height: var(--ball-size); +    background: linear-gradient( +      to right, +      var(--color-primary-light) 0%, +      var(--color-primary-lighter) 100% +    ); +    border-radius: 50%; +    animation: spinner 1.4s infinite ease-in-out both; + +    &:first-child { +      animation-delay: -0.32s; +    } + +    &:nth-child(2) { +      animation-delay: -0.16s; +    } +  } +} + +.body { +  color: var(--color-primary-darker); +} + +@keyframes spinner { +  0%, +  80%, +  100% { +    transform: scale(0); +  } + +  40% { +    transform: scale(1); +  } +} diff --git a/src/components/atoms/loaders/spinner.stories.tsx b/src/components/atoms/loaders/spinner/spinner.stories.tsx index 197d06c..e9dfae4 100644 --- a/src/components/atoms/loaders/spinner.stories.tsx +++ b/src/components/atoms/loaders/spinner/spinner.stories.tsx @@ -1,14 +1,14 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { ComponentMeta, ComponentStory } from '@storybook/react';  import { Spinner as SpinnerComponent } from './spinner';  /**   * Spinner - Storybook Meta   */  export default { -  title: 'Atoms/Loaders/Spinner', +  title: 'Atoms/Loaders',    component: SpinnerComponent,    argTypes: { -    message: { +    children: {        control: {          type: 'text',        }, @@ -29,14 +29,9 @@ const Template: ComponentStory<typeof SpinnerComponent> = (args) => (  );  /** - * Loaders Stories - Default Spinner + * Loaders Stories - Spinner   */  export const Spinner = Template.bind({}); - -/** - * Loaders Stories - Spinner with custom message - */ -export const SpinnerCustomMessage = Template.bind({}); -SpinnerCustomMessage.args = { -  message: 'Submitting...', +Spinner.args = { +  children: 'Submitting...',  }; diff --git a/src/components/atoms/loaders/spinner/spinner.test.tsx b/src/components/atoms/loaders/spinner/spinner.test.tsx new file mode 100644 index 0000000..733648b --- /dev/null +++ b/src/components/atoms/loaders/spinner/spinner.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { Spinner } from './spinner'; + +describe('Spinner', () => { +  it('renders a spinner', () => { +    const { container } = render(<Spinner />); +    expect(container).toBeInTheDocument(); +  }); + +  it('can render a spinner with a custom message', () => { +    const customMsg = 'Submitting'; + +    render(<Spinner>{customMsg}</Spinner>); +    expect(rtlScreen.getByText(customMsg)).toBeInTheDocument(); +  }); + +  it('can render a spinner with a custom message at the bottom', () => { +    const customMsg = 'necessitatibus'; + +    render(<Spinner position="bottom">{customMsg}</Spinner>); +    expect(rtlScreen.getByText(customMsg).parentElement).toHaveClass( +      'wrapper--bottom' +    ); +  }); + +  it('can render a spinner with a custom message on the left', () => { +    const customMsg = 'eos'; + +    render(<Spinner position="left">{customMsg}</Spinner>); +    expect(rtlScreen.getByText(customMsg).parentElement).toHaveClass( +      'wrapper--left' +    ); +  }); + +  it('can render a spinner with a custom message on the right', () => { +    const customMsg = 'neque'; + +    render(<Spinner position="right">{customMsg}</Spinner>); +    expect(rtlScreen.getByText(customMsg).parentElement).toHaveClass( +      'wrapper--right' +    ); +  }); + +  it('can render a spinner with a custom message on the top', () => { +    const customMsg = 'vero'; + +    render(<Spinner position="top">{customMsg}</Spinner>); +    expect(rtlScreen.getByText(customMsg).parentElement).toHaveClass( +      'wrapper--top' +    ); +  }); +}); diff --git a/src/components/atoms/loaders/spinner/spinner.tsx b/src/components/atoms/loaders/spinner/spinner.tsx new file mode 100644 index 0000000..6c6c23c --- /dev/null +++ b/src/components/atoms/loaders/spinner/spinner.tsx @@ -0,0 +1,42 @@ +import type { FC, HTMLAttributes, ReactNode } from 'react'; +import type { Position } from '../../../../types'; +import styles from './spinner.module.scss'; + +export type SpinnerProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & { +  /** +   * The loading message. +   */ +  children?: ReactNode; +  /** +   * Define the position of the loading message if any. +   * +   * @default 'right' +   */ +  position?: Exclude<Position, 'center'>; +}; + +/** + * Spinner component + * + * Render a loading message with animation. + */ +export const Spinner: FC<SpinnerProps> = ({ +  children, +  className = '', +  position = 'right', +  ...props +}) => { +  const positionClass = styles[`wrapper--${position}`]; +  const wrapperClass = `${styles.wrapper} ${positionClass} ${className}`; + +  return ( +    <div {...props} className={wrapperClass}> +      <div aria-hidden className={styles.icon}> +        <div className={styles.icon__ball} /> +        <div className={styles.icon__ball} /> +        <div className={styles.icon__ball} /> +      </div> +      <div className={styles.body}>{children}</div> +    </div> +  ); +}; diff --git a/src/components/organisms/forms/comment-form/comment-form.tsx b/src/components/organisms/forms/comment-form/comment-form.tsx index e645ede..b5f2d64 100644 --- a/src/components/organisms/forms/comment-form/comment-form.tsx +++ b/src/components/organisms/forms/comment-form/comment-form.tsx @@ -117,6 +117,12 @@ export const CommentForm: FC<CommentFormProps> = ({      id: 'dz2kDV',    }); +  const loadingMsg = intl.formatMessage({ +    defaultMessage: 'Submitting...', +    description: 'CommentForm: spinner message on submit', +    id: 'IY5ew6', +  }); +    const formAriaLabel = title ? undefined : formTitle;    const formId = useId();    const formLabelledBy = title ? formId : undefined; @@ -246,15 +252,7 @@ export const CommentForm: FC<CommentFormProps> = ({            id: 'OL0Yzx',          })}        </Button> -      {isSubmitting ? ( -        <Spinner -          message={intl.formatMessage({ -            defaultMessage: 'Submitting...', -            description: 'CommentForm: spinner message on submit', -            id: 'IY5ew6', -          })} -        /> -      ) : null} +      {isSubmitting ? <Spinner>{loadingMsg}</Spinner> : null}        {Notice}      </Form>    ); diff --git a/src/components/organisms/forms/contact-form/contact-form.tsx b/src/components/organisms/forms/contact-form/contact-form.tsx index 6208b94..89fd331 100644 --- a/src/components/organisms/forms/contact-form/contact-form.tsx +++ b/src/components/organisms/forms/contact-form/contact-form.tsx @@ -1,4 +1,13 @@ -import { ChangeEvent, FC, FormEvent, ReactNode, useState } from 'react'; +/* eslint-disable max-statements */ +import { +  type ChangeEvent, +  type FC, +  type FormEvent, +  type ReactNode, +  useState, +  useCallback, +  useMemo, +} from 'react';  import { useIntl } from 'react-intl';  import { Button, Form, Input, Label, Spinner, TextArea } from '../../../atoms';  import { LabelledField } from '../../../molecules'; @@ -38,51 +47,54 @@ export const ContactForm: FC<ContactFormProps> = ({  }) => {    const formClass = `${styles.form} ${className}`;    const intl = useIntl(); -  const emptyForm: ContactFormData = { -    email: '', -    message: '', -    name: '', -    object: '', -  }; +  const emptyForm: ContactFormData = useMemo(() => { +    return { +      email: '', +      message: '', +      name: '', +      object: '', +    }; +  }, []);    const [data, setData] = useState(emptyForm);    const [isSubmitting, setIsSubmitting] = useState<boolean>(false);    /**     * Reset all the form fields.     */ -  const resetForm = () => { +  const resetForm = useCallback(() => {      setData(emptyForm);      setIsSubmitting(false); -  }; +  }, [emptyForm]); -  const updateForm = ( -    e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> -  ) => { -    switch (e.target.name) { -      case 'email': -        setData((prevData) => { -          return { ...prevData, email: e.target.value }; -        }); -        break; -      case 'message': -        setData((prevData) => { -          return { ...prevData, message: e.target.value }; -        }); -        break; -      case 'name': -        setData((prevData) => { -          return { ...prevData, name: e.target.value }; -        }); -        break; -      case 'object': -        setData((prevData) => { -          return { ...prevData, object: e.target.value }; -        }); -        break; -      default: -        break; -    } -  }; +  const updateForm = useCallback( +    (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { +      switch (e.target.name) { +        case 'email': +          setData((prevData) => { +            return { ...prevData, email: e.target.value }; +          }); +          break; +        case 'message': +          setData((prevData) => { +            return { ...prevData, message: e.target.value }; +          }); +          break; +        case 'name': +          setData((prevData) => { +            return { ...prevData, name: e.target.value }; +          }); +          break; +        case 'object': +          setData((prevData) => { +            return { ...prevData, object: e.target.value }; +          }); +          break; +        default: +          break; +      } +    }, +    [] +  );    const formName = intl.formatMessage({      defaultMessage: 'Contact form', @@ -114,11 +126,20 @@ export const ContactForm: FC<ContactFormProps> = ({      id: 'yN5P+m',    }); -  const submitHandler = async (e: FormEvent) => { -    e.preventDefault(); -    setIsSubmitting(true); -    sendMail(data, resetForm).then(() => setIsSubmitting(false)); -  }; +  const loadingMsg = intl.formatMessage({ +    defaultMessage: 'Sending mail...', +    description: 'ContactForm: spinner message on submit', +    id: 'xaqaYQ', +  }); + +  const submitHandler = useCallback( +    async (e: FormEvent) => { +      e.preventDefault(); +      setIsSubmitting(true); +      await sendMail(data, resetForm).then(() => setIsSubmitting(false)); +    }, +    [data, resetForm, sendMail] +  );    return (      <Form aria-label={formName} className={formClass} onSubmit={submitHandler}> @@ -195,15 +216,7 @@ export const ContactForm: FC<ContactFormProps> = ({            id: 'VkAnvv',          })}        </Button> -      {isSubmitting && ( -        <Spinner -          message={intl.formatMessage({ -            defaultMessage: 'Sending mail...', -            description: 'ContactForm: spinner message on submit', -            id: 'xaqaYQ', -          })} -        /> -      )} +      {isSubmitting ? <Spinner>{loadingMsg}</Spinner> : null}        {Notice}      </Form>    ); diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx index f04ba74..5401ed1 100644 --- a/src/components/organisms/layout/posts-list.tsx +++ b/src/components/organisms/layout/posts-list.tsx @@ -165,8 +165,14 @@ export const PostsList: FC<PostsListProps> = ({    const loadMoreBody = intl.formatMessage({      defaultMessage: 'Load more articles?', -    id: 'uaqd5F',      description: 'PostsList: load more button', +    id: 'uaqd5F', +  }); + +  const loadingMoreArticles = intl.formatMessage({ +    defaultMessage: 'Loading more articles...', +    description: 'PostsList: loading more articles message', +    id: 'xYemkP',    });    /** @@ -224,7 +230,7 @@ export const PostsList: FC<PostsListProps> = ({    return (      <>        {getPosts()} -      {isLoading ? <Spinner /> : null} +      {isLoading ? <Spinner>{loadingMoreArticles}</Spinner> : null}        {isMounted ? getProgressBar() : getPagination()}      </>    ); diff --git a/src/i18n/en.json b/src/i18n/en.json index d8a982d..2faedfa 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -83,6 +83,10 @@      "defaultMessage": "Page not found.",      "description": "404Page: SEO - Meta description"    }, +  "4iYISO": { +    "defaultMessage": "Loading the requested article...", +    "description": "ArticlePage: loading article message" +  },    "50xc4o": {      "defaultMessage": "Read more articles about:",      "description": "ArticlePage: footer topics list label" @@ -187,6 +191,10 @@      "defaultMessage": "Reading time:",      "description": "Meta: reading time label"    }, +  "EeCqAE": { +    "defaultMessage": "Loading the search results...", +    "description": "SearchPage: loading search results message" +  },    "Es52wh": {      "defaultMessage": "Blog",      "description": "Breadcrumb: blog label" @@ -307,6 +315,10 @@      "defaultMessage": "CV",      "description": "Layout: main nav - cv link"    }, +  "RwI3B9": { +    "defaultMessage": "Loading the repository popularity...", +    "description": "ProjectsPage: loading repository popularity" +  },    "T4YA64": {      "defaultMessage": "Subscribe",      "description": "HomePage: RSS feed subscription text" @@ -535,10 +547,6 @@      "defaultMessage": "Share",      "description": "Sharing: widget title"    }, -  "q9cJQe": { -    "defaultMessage": "Loading...", -    "description": "Spinner: loading text" -  },    "qnwsWV": {      "defaultMessage": "Projects",      "description": "Layout: main nav - projects link" @@ -619,6 +627,10 @@      "defaultMessage": "Settings form",      "description": "SettingsModal: an accessible form name"    }, +  "xYemkP": { +    "defaultMessage": "Loading more articles...", +    "description": "PostsList: loading more articles message" +  },    "xaqaYQ": {      "defaultMessage": "Sending mail...",      "description": "ContactForm: spinner message on submit" diff --git a/src/i18n/fr.json b/src/i18n/fr.json index d64c930..0f79416 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -83,6 +83,10 @@      "defaultMessage": "Page non trouvée.",      "description": "404Page: SEO - Meta description"    }, +  "4iYISO": { +    "defaultMessage": "Chargement de l'article demandé…", +    "description": "ArticlePage: loading article message" +  },    "50xc4o": {      "defaultMessage": "Lire plus d’articles à propos de :",      "description": "ArticlePage: footer topics list label" @@ -187,6 +191,10 @@      "defaultMessage": "Temps de lecture :",      "description": "Meta: reading time label"    }, +  "EeCqAE": { +    "defaultMessage": "Chargement des résultats…", +    "description": "SearchPage: loading search results message" +  },    "Es52wh": {      "defaultMessage": "Blog",      "description": "Breadcrumb: blog label" @@ -307,6 +315,10 @@      "defaultMessage": "CV",      "description": "Layout: main nav - cv link"    }, +  "RwI3B9": { +    "defaultMessage": "Chargement de la popularité du dépôt…", +    "description": "ProjectsPage: loading repository popularity" +  },    "T4YA64": {      "defaultMessage": "Vous abonner",      "description": "HomePage: RSS feed subscription text" @@ -535,10 +547,6 @@      "defaultMessage": "Partager",      "description": "Sharing: widget title"    }, -  "q9cJQe": { -    "defaultMessage": "Chargement…", -    "description": "Spinner: loading text" -  },    "qnwsWV": {      "defaultMessage": "Projets",      "description": "Layout: main nav - projects link" @@ -619,6 +627,10 @@      "defaultMessage": "Formulaire des réglages",      "description": "SettingsModal: an accessible form name"    }, +  "xYemkP": { +    "defaultMessage": "Chargement des articles précédents…", +    "description": "PostsList: loading more articles message" +  },    "xaqaYQ": {      "defaultMessage": "Mail en cours d’envoi…",      "description": "ContactForm: spinner message on submit" diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx index 3e4c38f..523e21d 100644 --- a/src/pages/article/[slug].tsx +++ b/src/pages/article/[slug].tsx @@ -71,8 +71,13 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({    const { website } = useSettings();    const prismPlugins: OptionalPrismPlugin[] = ['command-line', 'line-numbers'];    const { attributes, className } = usePrism({ plugins: prismPlugins }); +  const loadingArticle = intl.formatMessage({ +    defaultMessage: 'Loading the requested article...', +    description: 'ArticlePage: loading article message', +    id: '4iYISO', +  }); -  if (isFallback || !article) return <Spinner />; +  if (isFallback || !article) return <Spinner>{loadingArticle}</Spinner>;    const { content, id, intro, meta, title } = article;    const { author, commentsCount, cover, dates, seo, thematics, topics } = meta; diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx index afcf060..717ae13 100644 --- a/src/pages/projets/[slug].tsx +++ b/src/pages/projets/[slug].tsx @@ -170,6 +170,12 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {      return links;    }; +  const loadingRepoPopularity = intl.formatMessage({ +    defaultMessage: 'Loading the repository popularity...', +    description: 'ProjectsPage: loading repository popularity', +    id: 'RwI3B9', +  }); +    const { isError, isLoading, data } = useGithubApi(      /*       * Repo should be defined for each project so for now it is safe for my @@ -182,7 +188,7 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {    );    if (isError) return 'Error'; -  if (isLoading || !data) return <Spinner />; +  if (isLoading || !data) return <Spinner aria-label={loadingRepoPopularity} />;    const getRepoPopularity = (repo: string) => {      const stars = intl.formatMessage( diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx index 971d04a..5acf352 100644 --- a/src/pages/recherche/index.tsx +++ b/src/pages/recherche/index.tsx @@ -151,6 +151,11 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({      id: 'N804XO',    });    const postsListBaseUrl = `${ROUTES.SEARCH}/page/`; +  const loadingResults = intl.formatMessage({ +    defaultMessage: 'Loading the search results...', +    description: 'SearchPage: loading search results message', +    id: 'EeCqAE', +  });    return (      <> @@ -211,7 +216,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({              total={totalArticles ?? 0}            />          ) : ( -          <Spinner /> +          <Spinner>{loadingResults}</Spinner>          )}          {error ? (            <Notice diff --git a/src/types/app.ts b/src/types/app.ts index 64bb3af..e237560 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -1,7 +1,7 @@ -import { NextPage } from 'next'; -import { AppProps as NextAppProps } from 'next/app'; -import { ReactElement, ReactNode } from 'react'; -import { MessageFormatElement } from 'react-intl'; +import type { NextPage } from 'next'; +import type { AppProps as NextAppProps } from 'next/app'; +import type { ReactElement, ReactNode } from 'react'; +import type { MessageFormatElement } from 'react-intl';  export type NextPageWithLayoutOptions = {    withExtraPadding?: boolean; @@ -9,15 +9,15 @@ export type NextPageWithLayoutOptions = {    useGrid?: boolean;  }; -export type NextPageWithLayout<T = {}> = NextPage<T> & { +export type NextPageWithLayout<T = object> = NextPage<T> & {    getLayout?: (      page: ReactElement,      options: NextPageWithLayoutOptions    ) => ReactNode;  }; -// modified version - allows for custom pageProps type, falling back to 'any' -type AppProps<P = any> = { +// modified version - allows custom pageProps type, falling back to 'unknown' +type AppProps<P = unknown> = {    pageProps: P;  } & Omit<NextAppProps<P>, 'pageProps'>; @@ -130,3 +130,5 @@ export type Topic = Page<'topic'>;  export type Slug = {    slug: string;  }; + +export type Position = 'bottom' | 'center' | 'left' | 'right' | 'top'; | 
