Explorar el Código

refactor: make components more modular

would help in implementing name route

also did some stylistic changes
zyachel hace 2 años
padre
commit
18ca98fd4a
Se han modificado 43 ficheros con 756 adiciones y 795 borrados
  1. 2 1
      .prettierrc
  2. 29 0
      src/components/card/Card.tsx
  3. 45 0
      src/components/card/CardBasic.tsx
  4. 51 0
      src/components/card/CardCast.tsx
  5. 42 0
      src/components/card/CardResult.tsx
  6. 63 0
      src/components/card/CardTitle.tsx
  7. 5 0
      src/components/card/index.tsx
  8. 4 7
      src/components/error/ErrorInfo.tsx
  9. 8 17
      src/components/find/Company.tsx
  10. 7 15
      src/components/find/Keyword.tsx
  11. 10 35
      src/components/find/Person.tsx
  12. 25 47
      src/components/find/Title.tsx
  13. 11 13
      src/components/find/index.tsx
  14. 6 10
      src/components/media/Media.tsx
  15. 6 4
      src/components/meta/Meta.tsx
  16. 77 125
      src/components/title/Basic.tsx
  17. 11 34
      src/components/title/Cast.tsx
  18. 11 43
      src/components/title/MoreLikeThis.tsx
  19. 6 9
      src/components/title/index.tsx
  20. 5 5
      src/context/theme-context.tsx
  21. 3 0
      src/interfaces/shared/index.ts
  22. 0 2
      src/interfaces/shared/title.ts
  23. 20 23
      src/layouts/Footer.tsx
  24. 3 3
      src/layouts/Layout.tsx
  25. 13 24
      src/pages/title/[titleId]/index.tsx
  26. 5 5
      src/styles/abstracts/variables/_misc.scss
  27. 2 6
      src/styles/abstracts/variables/_themes.scss
  28. 97 0
      src/styles/modules/components/card/card-basic.module.scss
  29. 44 0
      src/styles/modules/components/card/card-cast.module.scss
  30. 62 0
      src/styles/modules/components/card/card-result.module.scss
  31. 56 0
      src/styles/modules/components/card/card-title.module.scss
  32. 11 0
      src/styles/modules/components/card/card.module.scss
  33. 0 13
      src/styles/modules/components/find/company.module.scss
  34. 0 13
      src/styles/modules/components/find/keyword.module.scss
  35. 1 62
      src/styles/modules/components/find/person.module.scss
  36. 4 4
      src/styles/modules/components/find/results.module.scss
  37. 1 53
      src/styles/modules/components/find/title.module.scss
  38. 0 0
      src/styles/modules/components/media/media.module.scss
  39. 0 92
      src/styles/modules/components/title/basic.module.scss
  40. 0 50
      src/styles/modules/components/title/cast.module.scss
  41. 0 74
      src/styles/modules/components/title/more-like-this.module.scss
  42. 3 3
      src/styles/modules/layout/footer.module.scss
  43. 7 3
      src/utils/redis.ts

+ 2 - 1
.prettierrc

@@ -4,5 +4,6 @@
   "arrowParens": "avoid",
   "arrowParens": "avoid",
   "semi": true,
   "semi": true,
   "singleQuote": true,
   "singleQuote": true,
-  "jsxSingleQuote": true
+  "jsxSingleQuote": true,
+  "printWidth": 100
 }
 }

+ 29 - 0
src/components/card/Card.tsx

@@ -0,0 +1,29 @@
+import type { ReactNode, ElementType, ComponentPropsWithoutRef } from 'react';
+import styles from 'src/styles/modules/components/card/card.module.scss';
+
+// ensuring that other attributes to <Card/> are correct based on the value of 'as' prop.
+// a cheap implementation of as prop found in libraries like CharkaUI or MaterialUI.
+type Props<T extends ElementType> = {
+  children: ReactNode;
+  as?: T | 'section';
+  hoverable?: true;
+} & ComponentPropsWithoutRef<T>;
+
+const Card = <T extends ElementType = 'li'>({
+  children,
+  as,
+  hoverable,
+  className,
+  ...rest
+}: Props<T>) => {
+  const Component = as ?? 'li';
+  const classNames = `${hoverable ? styles.hoverable : ''} ${styles.card} ${className}`;
+
+  return (
+    <Component className={classNames} {...rest}>
+      {children}
+    </Component>
+  );
+};
+
+export default Card;

+ 45 - 0
src/components/card/CardBasic.tsx

@@ -0,0 +1,45 @@
+import { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react';
+import Image from 'next/future/image';
+import Card from './Card';
+import { getProxiedIMDbImgUrl, modifyIMDbImg } from 'src/utils/helpers';
+import styles from 'src/styles/modules/components/card/card-basic.module.scss';
+
+type Props = {
+  children: ReactNode;
+  className?: string;
+  image?: string;
+  title: string;
+} & ComponentPropsWithoutRef<'section'>;
+
+const CardBasic = ({ image, children, className, title, ...rest }: Props) => {
+  const style: CSSProperties = {
+    backgroundImage: image && `url(${getProxiedIMDbImgUrl(modifyIMDbImg(image, 300))})`,
+  };
+
+  return (
+    <Card as='section' className={`${styles.container} ${className}`} {...rest}>
+      <div className={styles.imageContainer} style={style}>
+        {image ? (
+          <Image
+            className={styles.image}
+            src={modifyIMDbImg(image)}
+            alt=''
+            priority
+            fill
+            sizes='300px'
+          />
+        ) : (
+          <svg className={styles.imageNA}>
+            <use href='/svg/sprite.svg#icon-image-slash' />
+          </svg>
+        )}
+      </div>
+      <div className={styles.info}>
+        <h1 className={`${styles.title} heading heading__primary`}>{title}</h1>
+        {children}
+      </div>
+    </Card>
+  );
+};
+
+export default CardBasic;

+ 51 - 0
src/components/card/CardCast.tsx

@@ -0,0 +1,51 @@
+import Card from './Card';
+import styles from 'src/styles/modules/components/card/card-cast.module.scss';
+import { ComponentPropsWithoutRef, ReactNode } from 'react';
+import Link from 'next/link';
+import Image from 'next/future/image';
+import { modifyIMDbImg } from 'src/utils/helpers';
+
+type Props = {
+  link: string;
+  name: string;
+  characters: string[] | null;
+  attributes: string[] | null;
+  image?: string | null;
+  children?: ReactNode;
+} & ComponentPropsWithoutRef<'li'>;
+
+const CardCast = ({ link, name, image, children, characters, attributes, ...rest }: Props) => {
+  return (
+    <Card hoverable {...rest}>
+      <Link href={link}>
+        <a className={styles.item}>
+          <div className={styles.imgContainer}>
+            {image ? (
+              <Image
+                src={modifyIMDbImg(image, 400)}
+                alt=''
+                fill
+                className={styles.img}
+                sizes='200px'
+              />
+            ) : (
+              <svg className={styles.imgNA}>
+                <use href='/svg/sprite.svg#icon-image-slash' />
+              </svg>
+            )}
+          </div>
+          <div className={styles.textContainer}>
+            <p className={`heading ${styles.name}`}>{name}</p>
+            <p className={styles.role}>
+              {characters?.join(', ')}
+              {attributes && <span> ({attributes.join(', ')})</span>}
+            </p>
+            {children}
+          </div>
+        </a>
+      </Link>
+    </Card>
+  );
+};
+
+export default CardCast;

+ 42 - 0
src/components/card/CardResult.tsx

@@ -0,0 +1,42 @@
+import { ComponentPropsWithoutRef, ReactNode } from 'react';
+import Link from 'next/link';
+import Image from 'next/future/image';
+import Card from './Card';
+import { modifyIMDbImg } from 'src/utils/helpers';
+import styles from 'src/styles/modules/components/card/card-result.module.scss';
+
+type Props = {
+  link: string;
+  name: string;
+  image?: string;
+  showImage?: true;
+  children?: ReactNode;
+} & ComponentPropsWithoutRef<'li'>;
+
+const CardResult = ({ link, name, image, showImage, children, ...rest }: Props) => {
+  let ImageComponent = null;
+  if (showImage)
+    ImageComponent = image ? (
+      <Image src={modifyIMDbImg(image, 400)} alt='' fill className={styles.img} sizes='200px' />
+    ) : (
+      <svg className={styles.imgNA}>
+        <use href='/svg/sprite.svg#icon-image-slash' />
+      </svg>
+    );
+
+  return (
+    <Card hoverable {...rest}>
+      <Link href={link}>
+        <a className={`${styles.item} ${!showImage && styles.sansImage}`}>
+          <div className={styles.imgContainer}>{ImageComponent}</div>
+          <div className={styles.info}>
+            <p className={`heading ${styles.heading}`}>{name}</p>
+            {children}
+          </div>
+        </a>
+      </Link>
+    </Card>
+  );
+};
+
+export default CardResult;

+ 63 - 0
src/components/card/CardTitle.tsx

@@ -0,0 +1,63 @@
+import Card from './Card';
+import styles from 'src/styles/modules/components/card/card-title.module.scss';
+import { ComponentPropsWithoutRef, ReactNode } from 'react';
+import Link from 'next/link';
+import Image from 'next/future/image';
+import { formatNumber, modifyIMDbImg } from 'src/utils/helpers';
+
+type Props = {
+  link: string;
+  name: string;
+  titleType: string;
+  year?: { start: number; end: number | null };
+  ratings?: { avg: number | null; numVotes: number };
+  image?: string;
+  children?: ReactNode;
+} & ComponentPropsWithoutRef<'li'>;
+
+const CardTitle = ({ link, name, year, image, ratings, titleType, children, ...rest }: Props) => {
+  const years = year?.end ? `${year.start}-${year.end}` : year?.start;
+
+  return (
+    <Card hoverable {...rest}>
+      <Link href={link}>
+        <a className={styles.item}>
+          <div className={styles.imgContainer}>
+            {image ? (
+              <Image
+                src={modifyIMDbImg(image, 400)}
+                alt=''
+                fill
+                className={styles.img}
+                sizes='200px'
+              />
+            ) : (
+              <svg className={styles.imgNA}>
+                <use href='/svg/sprite.svg#icon-image-slash' />
+              </svg>
+            )}
+          </div>
+          <div className={styles.textContainer}>
+            <p className={`heading ${styles.name}`}>{name}</p>
+            <p>
+              <span>{titleType}</span>
+              <span>{years && ` (${years})`}</span>
+            </p>
+            {ratings?.avg && (
+              <p className={styles.rating}>
+                <span className={styles.ratingNum}>{ratings.avg}</span>
+                <svg className={styles.ratingIcon}>
+                  <use href='/svg/sprite.svg#icon-rating'></use>
+                </svg>
+                <span> ({formatNumber(ratings.numVotes)} votes)</span>
+              </p>
+            )}
+            {children}
+          </div>
+        </a>
+      </Link>
+    </Card>
+  );
+};
+
+export default CardTitle;

+ 5 - 0
src/components/card/index.tsx

@@ -0,0 +1,5 @@
+export { default as Card } from './Card';
+export { default as CardTitle } from './CardTitle';
+export { default as CardBasic } from './CardBasic';
+export { default as CardCast } from './CardCast';
+export { default as CardResult } from './CardResult';

+ 4 - 7
src/components/error/ErrorInfo.tsx

@@ -33,15 +33,12 @@ const ErrorInfo = ({ message, statusCode, misc }: Props) => {
         >
         >
           <title id='gnu-title'>GNU and Tux</title>
           <title id='gnu-title'>GNU and Tux</title>
           <desc id='gnu-desc'>
           <desc id='gnu-desc'>
-            A pencil drawing of a big gnu and a small penguin, both very sad.
-            GNU is despondently sitting on a bench, and Tux stands beside him,
-            looking down and patting him on the back.
+            A pencil drawing of a big gnu and a small penguin, both very sad. GNU is despondently
+            sitting on a bench, and Tux stands beside him, looking down and patting him on the back.
           </desc>
           </desc>
           <use href='/svg/sadgnu.svg#sad-gnu'></use>
           <use href='/svg/sadgnu.svg#sad-gnu'></use>
         </svg>
         </svg>
-        <h1 className={`heading heading__primary ${styles.heading}`}>
-          {title}
-        </h1>
+        <h1 className={`heading heading__primary ${styles.heading}`}>{title}</h1>
         {misc ? (
         {misc ? (
           <>
           <>
             <p>{misc.subtext}</p>
             <p>{misc.subtext}</p>
@@ -52,7 +49,7 @@ const ErrorInfo = ({ message, statusCode, misc }: Props) => {
         ) : (
         ) : (
           <p>
           <p>
             Go back to{' '}
             Go back to{' '}
-            <Link href='/about'>
+            <Link href='/'>
               <a className='link'>the homepage</a>
               <a className='link'>the homepage</a>
             </Link>
             </Link>
             .
             .

+ 8 - 17
src/components/find/Company.tsx

@@ -1,22 +1,13 @@
+import { CardResult } from 'src/components/card';
 import { Companies } from 'src/interfaces/shared/search';
 import { Companies } from 'src/interfaces/shared/search';
-import Link from 'next/link';
 
 
-import styles from 'src/styles/modules/components/find/company.module.scss';
+type Props = { company: Companies[number] };
 
 
-type Props = {
-  company: Companies[0];
-};
-
-const Company = ({ company }: Props) => {
-  return (
-    <li className={styles.company}>
-      <Link href={`name/${company.id}`}>
-        <a className={`heading ${styles.heading}`}>{company.name}</a>
-      </Link>
-      {company.country && <p>{company.country}</p>}
-      {!!company.type && <p>{company.type}</p>}
-    </li>
-  );
-};
+const Company = ({ company }: Props) => (
+  <CardResult name={company.name} link={`/search/title?companies=${company.id}`}>
+    {company.country && <p>{company.country}</p>}
+    {!!company.type && <p>{company.type}</p>}
+  </CardResult>
+);
 
 
 export default Company;
 export default Company;

+ 7 - 15
src/components/find/Keyword.tsx

@@ -1,20 +1,12 @@
-import Link from 'next/link';
+import { CardResult } from 'src/components/card';
 import { Keywords } from 'src/interfaces/shared/search';
 import { Keywords } from 'src/interfaces/shared/search';
-import styles from 'src/styles/modules/components/find/keyword.module.scss';
 
 
-type Props = {
-  keyword: Keywords[0];
-};
+type Props = { keyword: Keywords[number] };
 
 
-const Keyword = ({ keyword }: Props) => {
-  return (
-    <li className={styles.keyword}>
-      <Link href={`name/${keyword.id}`}>
-        <a className={`heading ${styles.heading}`}>{keyword.text}</a>
-      </Link>
-      {keyword.numTitles && <p>{keyword.numTitles} titles</p>}
-    </li>
-  );
-};
+const Keyword = ({ keyword }: Props) => (
+  <CardResult link={`/search/keyword?keywords=${keyword.text}`} name={keyword.text}>
+    {keyword.numTitles && <p>{keyword.numTitles} titles</p>}
+  </CardResult>
+);
 
 
 export default Keyword;
 export default Keyword;

+ 10 - 35
src/components/find/Person.tsx

@@ -1,44 +1,19 @@
-import Image from 'next/future/image';
-import Link from 'next/link';
+import { CardResult } from 'src/components/card';
 import { People } from 'src/interfaces/shared/search';
 import { People } from 'src/interfaces/shared/search';
-import { modifyIMDbImg } from 'src/utils/helpers';
 import styles from 'src/styles/modules/components/find/person.module.scss';
 import styles from 'src/styles/modules/components/find/person.module.scss';
 
 
-type Props = {
-  person: People[0];
-};
+type Props = { person: People[number] };
 
 
 const Person = ({ person }: Props) => {
 const Person = ({ person }: Props) => {
   return (
   return (
-    <li className={styles.person}>
-      <div className={styles.imgContainer} style={{ position: 'relative' }}>
-        {person.image ? (
-          <Image
-            src={modifyIMDbImg(person.image.url, 400)}
-            alt={person.image.caption}
-            fill
-            className={styles.img}
-          />
-        ) : (
-          <svg className={styles.imgNA}>
-            <use href='/svg/sprite.svg#icon-image-slash' />
-          </svg>
-        )}
-      </div>
-      <div className={styles.info}>
-        <Link href={`name/${person.id}`}>
-          <a className={`heading ${styles.heading}`}>{person.name}</a>
-        </Link>
-        {person.aka && <p>{person.aka}</p>}
-        {person.jobCateogry && <p>{person.jobCateogry}</p>}
-        {(person.knownForTitle || person.knownInYear) && (
-          <ul className={styles.basicInfo} aria-label='quick facts'>
-            {person.knownForTitle && <li>{person.knownForTitle}</li>}
-            {person.knownInYear && <li>{person.knownInYear}</li>}
-          </ul>
-        )}
-      </div>
-    </li>
+    <CardResult showImage name={person.name} link={`/name/${person.id}`} image={person.image?.url}>
+      <p>{person.aka}</p>
+      <p>{person.jobCateogry}</p>
+      <ul className={styles.basicInfo} aria-label='quick facts'>
+        {person.knownForTitle && <li>{person.knownForTitle}</li>}
+        {person.knownInYear && <li>{person.knownInYear}</li>}
+      </ul>
+    </CardResult>
   );
   );
 };
 };
 
 

+ 25 - 47
src/components/find/Title.tsx

@@ -1,58 +1,36 @@
-import Image from 'next/future/image';
 import Link from 'next/link';
 import Link from 'next/link';
+import { CardResult } from 'src/components/card';
 import { Titles } from 'src/interfaces/shared/search';
 import { Titles } from 'src/interfaces/shared/search';
-import { modifyIMDbImg } from 'src/utils/helpers';
 import styles from 'src/styles/modules/components/find/title.module.scss';
 import styles from 'src/styles/modules/components/find/title.module.scss';
 
 
-type Props = {
-  title: Titles[0];
-};
+type Props = { title: Titles[number] };
 
 
 const Title = ({ title }: Props) => {
 const Title = ({ title }: Props) => {
   return (
   return (
-    <li className={styles.title}>
-      <div className={styles.imgContainer}>
-        {title.image ? (
-          <Image
-            src={modifyIMDbImg(title.image.url, 400)}
-            alt={title.image.caption}
-            fill
-            className={styles.img}
-          />
-        ) : (
-          <svg className={styles.imgNA}>
-            <use href='/svg/sprite.svg#icon-image-slash' />
-          </svg>
-        )}
-      </div>
-      <div className={styles.info}>
-        <Link href={`/title/${title.id}`}>
-          <a className={`heading ${styles.heading}`}>{title.name}</a>
-        </Link>
-        <ul aria-label='quick facts' className={styles.basicInfo}>
-          {title.type && <li>{title.type}</li>}
-          {title.sAndE && <li>{title.sAndE}</li>}
-          {title.releaseYear && <li>{title.releaseYear}</li>}
+    <CardResult showImage name={title.name} link={`/title/${title.id}`} image={title.image?.url}>
+      <ul aria-label='quick facts' className={styles.basicInfo}>
+        <li>{title.type}</li>
+        <li>{title.sAndE}</li>
+        <li>{title.releaseYear}</li>
+      </ul>
+      {!!title.credits.length && (
+        <p className={styles.stars}>
+          <span>Stars: </span>
+          {title.credits.join(', ')}
+        </p>
+      )}
+      {title.seriesId && (
+        <ul aria-label='quick series facts' className={styles.seriesInfo}>
+          {title.seriesType && <li>{title.seriesType}</li>}
+          <li>
+            <Link href={`/title/${title.seriesId}`}>
+              <a className='link'>{title.seriesName}</a>
+            </Link>
+          </li>
+          {title.seriesReleaseYear && <li>{title.seriesReleaseYear}</li>}
         </ul>
         </ul>
-        {!!title.credits.length && (
-          <p className={styles.stars}>
-            <span>Stars: </span>
-            {title.credits.join(', ')}
-          </p>
-        )}
-        {title.seriesId && (
-          <ul aria-label='quick series facts' className={styles.seriesInfo}>
-            {title.seriesType && <li>{title.seriesType}</li>}
-            <li>
-              <Link href={`/title/${title.seriesId}`}>
-                <a className='link'>{title.seriesName}</a>
-              </Link>
-            </li>
-            {title.seriesReleaseYear && <li>{title.seriesReleaseYear}</li>}
-          </ul>
-        )}
-      </div>
-    </li>
+      )}
+    </CardResult>
   );
   );
 };
 };
 
 

+ 11 - 13
src/components/find/index.tsx

@@ -12,18 +12,16 @@ type Props = {
   title: string;
   title: string;
 };
 };
 
 
-const resultsExist = (results: Props['results']) => {
-  if (
-    !results ||
-    (!results.people.length &&
-      !results.keywords.length &&
-      !results.companies.length &&
-      !results.titles.length)
-  )
-    return false;
-
-  return true;
-};
+const resultsExist = (
+  results: Props['results']
+): results is NonNullable<Props['results']> =>
+  Boolean(
+    results &&
+      (results.people.length ||
+        results.keywords.length ||
+        results.companies.length ||
+        results.titles.length)
+  );
 
 
 // MAIN COMPONENT
 // MAIN COMPONENT
 const Results = ({ results, className, title }: Props) => {
 const Results = ({ results, className, title }: Props) => {
@@ -34,7 +32,7 @@ const Results = ({ results, className, title }: Props) => {
       </h1>
       </h1>
     );
     );
 
 
-  const { titles, people, keywords, companies, meta } = results!;
+  const { titles, people, keywords, companies, meta } = results;
   const titlesSectionHeading = getResTitleTypeHeading(
   const titlesSectionHeading = getResTitleTypeHeading(
     meta.type,
     meta.type,
     meta.titleType
     meta.titleType

+ 6 - 10
src/components/title/Media.tsx → src/components/media/Media.tsx

@@ -1,14 +1,16 @@
 import Image from 'next/future/image';
 import Image from 'next/future/image';
 import Link from 'next/link';
 import Link from 'next/link';
-import { Media } from 'src/interfaces/shared/title';
+import { Media } from 'src/interfaces/shared';
 import { getProxiedIMDbImgUrl, modifyIMDbImg } from 'src/utils/helpers';
 import { getProxiedIMDbImgUrl, modifyIMDbImg } from 'src/utils/helpers';
-import styles from 'src/styles/modules/components/title/media.module.scss';
+import styles from 'src/styles/modules/components/media/media.module.scss';
 
 
 type Props = {
 type Props = {
   className: string;
   className: string;
   media: Media;
   media: Media;
 };
 };
 
 
+// TODO: refactor this component.
+
 const Media = ({ className, media }: Props) => {
 const Media = ({ className, media }: Props) => {
   return (
   return (
     <div className={`${className} ${styles.media}`}>
     <div className={`${className} ${styles.media}`}>
@@ -21,13 +23,9 @@ const Media = ({ className, media }: Props) => {
               <div className={styles.trailer}>
               <div className={styles.trailer}>
                 <video
                 <video
                   aria-label='trailer video'
                   aria-label='trailer video'
-                  // it's a relatively new tag. hence jsx-all1 complains
-                  aria-description={media.trailer.caption}
                   controls
                   controls
                   playsInline
                   playsInline
-                  poster={getProxiedIMDbImgUrl(
-                    modifyIMDbImg(media.trailer.thumbnail)
-                  )}
+                  poster={getProxiedIMDbImgUrl(modifyIMDbImg(media.trailer.thumbnail))}
                   className={styles.trailer__video}
                   className={styles.trailer__video}
                   preload='none'
                   preload='none'
                 >
                 >
@@ -76,9 +74,7 @@ const Media = ({ className, media }: Props) => {
                   fill
                   fill
                   sizes='400px'
                   sizes='400px'
                 />
                 />
-                <figcaption className={styles.image__caption}>
-                  {image.caption.plainText}
-                </figcaption>
+                <figcaption className={styles.image__caption}>{image.caption.plainText}</figcaption>
               </figure>
               </figure>
             ))}
             ))}
           </div>
           </div>

+ 6 - 4
src/components/meta/Meta.tsx

@@ -1,4 +1,5 @@
 import Head from 'next/head';
 import Head from 'next/head';
+import { ReactNode } from 'react';
 
 
 type Props = {
 type Props = {
   title: string;
   title: string;
@@ -6,11 +7,15 @@ type Props = {
   imgUrl?: string;
   imgUrl?: string;
 };
 };
 
 
+const BASE_URL = process.env.NEXT_PUBLIC_URL ?? 'https://iket.me';
+
 const Meta = ({
 const Meta = ({
   title,
   title,
   description = 'libremdb, a free & open source IMDb front-end.',
   description = 'libremdb, a free & open source IMDb front-end.',
   imgUrl = 'icon.svg',
   imgUrl = 'icon.svg',
 }: Props) => {
 }: Props) => {
+  const url = new URL(imgUrl, BASE_URL);
+
   return (
   return (
     <Head>
     <Head>
       <meta charSet='UTF-8' />
       <meta charSet='UTF-8' />
@@ -30,10 +35,7 @@ const Meta = ({
       <meta property='og:site_name' content='libremdb' />
       <meta property='og:site_name' content='libremdb' />
       <meta property='og:locale' content='en_US' />
       <meta property='og:locale' content='en_US' />
       <meta property='og:type' content='video.movie' />
       <meta property='og:type' content='video.movie' />
-      <meta
-        property='og:image'
-        content={`${process.env.NEXT_PUBLIC_URL}/${imgUrl}`}
-      />
+      <meta property='og:image' content={url.toString()} />
     </Head>
     </Head>
   );
   );
 };
 };

+ 77 - 125
src/components/title/Basic.tsx

@@ -1,13 +1,8 @@
 import { Fragment } from 'react';
 import { Fragment } from 'react';
-import Image from 'next/future/image';
 import Link from 'next/link';
 import Link from 'next/link';
+import { CardBasic } from 'src/components/card';
 import { Basic } from 'src/interfaces/shared/title';
 import { Basic } from 'src/interfaces/shared/title';
-import {
-  formatNumber,
-  formatTime,
-  getProxiedIMDbImgUrl,
-  modifyIMDbImg,
-} from 'src/utils/helpers';
+import { formatNumber, formatTime } from 'src/utils/helpers';
 import styles from 'src/styles/modules/components/title/basic.module.scss';
 import styles from 'src/styles/modules/components/title/basic.module.scss';
 
 
 type Props = {
 type Props = {
@@ -23,135 +18,92 @@ const Basic = ({ data, className }: Props) => {
       : data.releaseYear?.start;
       : data.releaseYear?.start;
 
 
   return (
   return (
-    <section
-      // role is valid but not known to jsx-a11y
-      // aria-description={`basic info for '${data.title}'`}
-      // style={{ backgroundImage: data.poster && `url(${data.poster?.url})` }}
+    <CardBasic
       className={`${styles.container} ${className}`}
       className={`${styles.container} ${className}`}
+      image={data.poster?.url}
+      title={data.title}
     >
     >
-      <div
-        className={styles.imageContainer}
-        style={{
-          backgroundImage:
-            data.poster &&
-            `url(${getProxiedIMDbImgUrl(modifyIMDbImg(data.poster.url, 300))})`,
-        }}
-      >
-        {data.poster ? (
-          <Image
-            className={styles.image}
-            src={modifyIMDbImg(data.poster.url)}
-            alt={data.poster.caption}
-            priority
-            fill
-            sizes='300px'
-          />
-        ) : (
-          <svg className={styles.image__NA}>
-            <use href='/svg/sprite.svg#icon-image-slash' />
-          </svg>
+      <ul className={styles.meta} aria-label='quick facts'>
+        {data.status && data.status.id !== 'released' && (
+          <li className={styles.meta__text}>{data.status.text}</li>
         )}
         )}
-      </div>
-      <div className={styles.info}>
-        <h1 className={`${styles.title} heading heading__primary`}>
-          {data.title}
-        </h1>
-        <ul className={styles.meta} aria-label='quick facts'>
-          {data.status && data.status.id !== 'released' && (
-            <li className={styles.meta__text}>{data.status.text}</li>
-          )}
-          <li className={styles.meta__text}>{data.type.name}</li>
-          {data.releaseYear && (
-            <li className={styles.meta__text}>{releaseTime}</li>
-          )}
-          {data.ceritficate && (
-            <li className={styles.meta__text}>{data.ceritficate}</li>
-          )}
-          {data.runtime && (
-            <li className={styles.meta__text}>{formatTime(data.runtime)}</li>
-          )}
-        </ul>
-        <div className={styles.ratings}>
-          {data.ratings.avg && (
-            <>
-              <p className={styles.rating}>
-                <span className={styles.rating__num}>{data.ratings.avg}</span>
-                <svg className={styles.rating__icon}>
-                  <use href='/svg/sprite.svg#icon-rating'></use>
-                </svg>
-                <span className={styles.rating__text}> Avg. rating</span>
-              </p>
-              <p className={styles.rating}>
-                <span className={styles.rating__num}>
-                  {formatNumber(data.ratings.numVotes)}
-                </span>
-                <svg className={styles.rating__icon}>
-                  <use href='/svg/sprite.svg#icon-like-dislike'></use>
-                </svg>
-                <span className={styles.rating__text}> No. of votes</span>
-              </p>
-            </>
-          )}
-          {data.ranking && (
+        <li className={styles.meta__text}>{data.type.name}</li>
+        {data.releaseYear && <li className={styles.meta__text}>{releaseTime}</li>}
+        {data.ceritficate && <li className={styles.meta__text}>{data.ceritficate}</li>}
+        {data.runtime && <li className={styles.meta__text}>{formatTime(data.runtime)}</li>}
+      </ul>
+      <div className={styles.ratings}>
+        {data.ratings.avg && (
+          <>
             <p className={styles.rating}>
             <p className={styles.rating}>
-              <span className={styles.rating__num}>
-                {formatNumber(data.ranking.position)}
-              </span>
+              <span className={styles.rating__num}>{data.ratings.avg}</span>
               <svg className={styles.rating__icon}>
               <svg className={styles.rating__icon}>
-                <use href='/svg/sprite.svg#icon-graph-rising'></use>
+                <use href='/svg/sprite.svg#icon-rating'></use>
               </svg>
               </svg>
-              <span className={styles.rating__text}>
-                {' '}
-                Popularity (
-                <span className={styles.rating__sub}>
-                  {data.ranking.direction === 'UP'
-                    ? `\u2191${formatNumber(data.ranking.change)}`
-                    : data.ranking.direction === 'DOWN'
-                    ? `\u2193${formatNumber(data.ranking.change)}`
-                    : ''}
-                </span>
-                )
-              </span>
+              <span className={styles.rating__text}> Avg. rating</span>
             </p>
             </p>
-          )}
-        </div>
-
-        {!!data.genres.length && (
-          <p className={styles.genres}>
-            <span className={styles.genres__heading}>Genres: </span>
-            {data.genres.map((genre, i) => (
-              <Fragment key={genre.id}>
-                {i > 0 && ', '}
-                <Link href={`/search/title?genres=${genre.id}`}>
-                  <a className={styles.link}>{genre.text}</a>
-                </Link>
-              </Fragment>
-            ))}
-          </p>
+            <p className={styles.rating}>
+              <span className={styles.rating__num}>{formatNumber(data.ratings.numVotes)}</span>
+              <svg className={styles.rating__icon}>
+                <use href='/svg/sprite.svg#icon-like-dislike'></use>
+              </svg>
+              <span className={styles.rating__text}> No. of votes</span>
+            </p>
+          </>
         )}
         )}
-        {
-          <p className={styles.overview}>
-            <span className={styles.overview__heading}>Plot: </span>
-            <span className={styles.overview__text}>{data.plot || '-'}</span>
-          </p>
-        }
-        {data.primaryCrew.map(crewType => (
-          <p className={styles.crewType} key={crewType.type.id}>
-            <span className={styles.crewType__heading}>
-              {`${crewType.type.category}: `}
+        {data.ranking && (
+          <p className={styles.rating}>
+            <span className={styles.rating__num}>{formatNumber(data.ranking.position)}</span>
+            <svg className={styles.rating__icon}>
+              <use href='/svg/sprite.svg#icon-graph-rising'></use>
+            </svg>
+            <span className={styles.rating__text}>
+              {' '}
+              Popularity (
+              <span className={styles.rating__sub}>
+                {data.ranking.direction === 'UP'
+                  ? `\u2191${formatNumber(data.ranking.change)}`
+                  : data.ranking.direction === 'DOWN'
+                  ? `\u2193${formatNumber(data.ranking.change)}`
+                  : ''}
+              </span>
+              )
             </span>
             </span>
-            {crewType.crew.map((crew, i) => (
-              <Fragment key={crew.id}>
-                {i > 0 && ', '}
-                <Link href={`/name/${crew.id}`}>
-                  <a className={styles.link}>{crew.name}</a>
-                </Link>
-              </Fragment>
-            ))}
           </p>
           </p>
-        ))}
+        )}
       </div>
       </div>
-    </section>
+
+      {!!data.genres.length && (
+        <p className={styles.genres}>
+          <span className={styles.genres__heading}>Genres: </span>
+          {data.genres.map((genre, i) => (
+            <Fragment key={genre.id}>
+              {i > 0 && ', '}
+              <Link href={`/search/title?genres=${genre.id}`}>
+                <a className={styles.link}>{genre.text}</a>
+              </Link>
+            </Fragment>
+          ))}
+        </p>
+      )}
+      <p className={styles.overview}>
+        <span className={styles.overview__heading}>Plot: </span>
+        <span className={styles.overview__text}>{data.plot || '-'}</span>
+      </p>
+      {data.primaryCrew.map(crewType => (
+        <p className={styles.crewType} key={crewType.type.id}>
+          <span className={styles.crewType__heading}>{`${crewType.type.category}: `}</span>
+          {crewType.crew.map((crew, i) => (
+            <Fragment key={crew.id}>
+              {i > 0 && ', '}
+              <Link href={`/name/${crew.id}`}>
+                <a className={styles.link}>{crew.name}</a>
+              </Link>
+            </Fragment>
+          ))}
+        </p>
+      ))}
+    </CardBasic>
   );
   );
 };
 };
 
 

+ 11 - 34
src/components/title/Cast.tsx

@@ -1,7 +1,5 @@
-import Image from 'next/future/image';
-import Link from 'next/link';
+import { CardCast } from 'src/components/card';
 import { Cast } from 'src/interfaces/shared/title';
 import { Cast } from 'src/interfaces/shared/title';
-import { modifyIMDbImg } from 'src/utils/helpers';
 import styles from 'src/styles/modules/components/title/cast.module.scss';
 import styles from 'src/styles/modules/components/title/cast.module.scss';
 
 
 type Props = {
 type Props = {
@@ -10,46 +8,25 @@ type Props = {
 };
 };
 
 
 const Cast = ({ className, cast }: Props) => {
 const Cast = ({ className, cast }: Props) => {
-  if (!cast.length) return <></>;
+  if (!cast.length) return null;
 
 
   return (
   return (
     <section className={`${className} ${styles.container}`}>
     <section className={`${className} ${styles.container}`}>
       <h2 className='heading heading__secondary'>Cast</h2>
       <h2 className='heading heading__secondary'>Cast</h2>
       <ul className={styles.cast}>
       <ul className={styles.cast}>
         {cast.map(member => (
         {cast.map(member => (
-          <li key={member.id} className={styles.member}>
-            <div className={styles.member__imgContainer}>
-              {member.image ? (
-                <Image
-                  src={modifyIMDbImg(member.image, 400)}
-                  alt=''
-                  fill
-                  className={styles.member__img}
-                  sizes='200px'
-                />
-              ) : (
-                <svg className={styles.member__imgNA}>
-                  <use href='/svg/sprite.svg#icon-image-slash' />
-                </svg>
-              )}
-            </div>
-            <div className={styles.member__textContainer}>
-              <p>
-                <Link href={`/name/${member.id}`}>
-                  <a className={styles.member__name}>{member.name}</a>
-                </Link>
-              </p>
-              <p className={styles.member__role}>
-                {member.characters?.join(', ')}
-                {member.attributes && (
-                  <span> ({member.attributes.join(', ')})</span>
-                )}
-              </p>
-            </div>
-          </li>
+          <CardCast
+            key={member.id}
+            link={`/name/${member.id}`}
+            name={member.name}
+            image={member.image}
+            characters={member.characters}
+            attributes={member.attributes}
+          />
         ))}
         ))}
       </ul>
       </ul>
     </section>
     </section>
   );
   );
 };
 };
+
 export default Cast;
 export default Cast;

+ 11 - 43
src/components/title/MoreLikeThis.tsx

@@ -1,7 +1,5 @@
-import Image from 'next/future/image';
-import Link from 'next/link';
+import { CardTitle } from 'src/components/card';
 import { MoreLikeThis } from 'src/interfaces/shared/title';
 import { MoreLikeThis } from 'src/interfaces/shared/title';
-import { formatNumber, modifyIMDbImg } from 'src/utils/helpers';
 import styles from 'src/styles/modules/components/title/more-like-this.module.scss';
 import styles from 'src/styles/modules/components/title/more-like-this.module.scss';
 
 
 type Props = {
 type Props = {
@@ -10,52 +8,22 @@ type Props = {
 };
 };
 
 
 const MoreLikeThis = ({ className, data }: Props) => {
 const MoreLikeThis = ({ className, data }: Props) => {
-  if (!data.length) return <></>;
+  if (!data.length) return null;
 
 
   return (
   return (
     <section className={`${className} ${styles.morelikethis}`}>
     <section className={`${className} ${styles.morelikethis}`}>
       <h2 className='heading heading__secondary'>More like this</h2>
       <h2 className='heading heading__secondary'>More like this</h2>
       <ul className={styles.container}>
       <ul className={styles.container}>
         {data.map(title => (
         {data.map(title => (
-          <li key={title.id}>
-            <Link href={`/title/${title.id}`}>
-              <a className={styles.item}>
-                <div className={styles.item__imgContainer}>
-                  {title.poster ? (
-                    <Image
-                      src={modifyIMDbImg(title.poster.url, 400)}
-                      alt=''
-                      fill
-                      className={styles.item__img}
-                      sizes='200px'
-                    />
-                  ) : (
-                    <svg className={styles.item__imgNA}>
-                      <use href='/svg/sprite.svg#icon-image-slash' />
-                    </svg>
-                  )}
-                </div>
-                <div className={styles.item__textContainer}>
-                  <h3 className={`heading ${styles.item__heading}`}>
-                    {title.title}
-                  </h3>
-                  {title.ratings.avg && (
-                    <p className={styles.item__rating}>
-                      <span className={styles.item__ratingNum}>
-                        {title.ratings.avg}
-                      </span>
-                      <svg className={styles.item__ratingIcon}>
-                        <use href='/svg/sprite.svg#icon-rating'></use>
-                      </svg>
-                      <span>
-                        ({formatNumber(title.ratings.numVotes)} votes)
-                      </span>
-                    </p>
-                  )}
-                </div>
-              </a>
-            </Link>
-          </li>
+          <CardTitle
+            key={title.id}
+            link={`/title/${title.id}`}
+            name={title.title}
+            titleType={title.type.text}
+            image={title.poster?.url}
+            year={title.releaseYear}
+            ratings={title.ratings}
+          />
         ))}
         ))}
       </ul>
       </ul>
     </section>
     </section>

+ 6 - 9
src/components/title/index.tsx

@@ -1,9 +1,6 @@
-import Basic from './Basic';
-import Cast from './Cast';
-import DidYouKnow from './DidYouKnow';
-import Info from './Info';
-import Media from './Media';
-import MoreLikeThis from './MoreLikeThis';
-import Reviews from './Reviews';
-
-export { Basic, Cast, DidYouKnow, Info, Media, MoreLikeThis, Reviews };
+export { default as Basic } from './Basic';
+export { default as Cast } from './Cast';
+export { default as DidYouKnow } from './DidYouKnow';
+export { default as Info } from './Info';
+export { default as MoreLikeThis } from './MoreLikeThis';
+export { default as Reviews } from './Reviews';

+ 5 - 5
src/context/theme-context.tsx

@@ -5,9 +5,9 @@ const getInitialTheme = () => {
   // for server-side rendering, as window isn't availabe there
   // for server-side rendering, as window isn't availabe there
   if (typeof window === 'undefined') return 'light';
   if (typeof window === 'undefined') return 'light';
 
 
-  const userPrefersTheme = isLocalStorageAvailable()
-    ? window.localStorage.getItem('theme')
-    : null;
+  const userPrefersTheme = (
+    isLocalStorageAvailable() ? window.localStorage.getItem('theme') : null
+  ) as 'light' | 'dark' | null;
   const browserPrefersDarkTheme = window.matchMedia(
   const browserPrefersDarkTheme = window.matchMedia(
     '(prefers-color-scheme: dark)'
     '(prefers-color-scheme: dark)'
   ).matches;
   ).matches;
@@ -28,7 +28,7 @@ const updateMetaTheme = () => {
 
 
 const initialContext = {
 const initialContext = {
   theme: '',
   theme: '',
-  setTheme: (theme: string) => {},
+  setTheme: (theme: ReturnType<typeof getInitialTheme>) => { },
 };
 };
 
 
 export const themeContext = createContext(initialContext);
 export const themeContext = createContext(initialContext);
@@ -36,7 +36,7 @@ export const themeContext = createContext(initialContext);
 const ThemeProvider = ({ children }: { children: ReactNode }) => {
 const ThemeProvider = ({ children }: { children: ReactNode }) => {
   const [curTheme, setCurTheme] = useState(getInitialTheme);
   const [curTheme, setCurTheme] = useState(getInitialTheme);
 
 
-  const setTheme = (theme: string) => {
+  const setTheme = (theme: typeof curTheme) => {
     setCurTheme(theme);
     setCurTheme(theme);
     if (isLocalStorageAvailable()) window.localStorage.setItem('theme', theme);
     if (isLocalStorageAvailable()) window.localStorage.setItem('theme', theme);
     document.documentElement.dataset.theme = theme;
     document.documentElement.dataset.theme = theme;

+ 3 - 0
src/interfaces/shared/index.ts

@@ -0,0 +1,3 @@
+import type Name from './name';
+
+export type Media = Name['media']; // exactly the same in title and name

+ 0 - 2
src/interfaces/shared/title.ts

@@ -1,8 +1,6 @@
 import cleanTitle from 'src/utils/cleaners/title';
 import cleanTitle from 'src/utils/cleaners/title';
 import title from 'src/utils/fetchers/title';
 import title from 'src/utils/fetchers/title';
 
 
-export type AxiosTitleRes = Awaited<ReturnType<typeof title>>;
-
 // for full title
 // for full title
 type Title = ReturnType<typeof cleanTitle>;
 type Title = ReturnType<typeof cleanTitle>;
 export type { Title as default };
 export type { Title as default };

+ 20 - 23
src/layouts/Footer.tsx

@@ -2,35 +2,32 @@ import Link from 'next/link';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import styles from '../styles/modules/layout/footer.module.scss';
 import styles from '../styles/modules/layout/footer.module.scss';
 
 
+const links = [
+  { path: '/about', text: 'About' },
+  { path: '/find', text: 'Find' },
+  { path: '/privacy', text: 'Privacy' },
+  { path: '/contact', text: 'Contact' },
+] as const;
+
 const Footer = () => {
 const Footer = () => {
   const { pathname } = useRouter();
   const { pathname } = useRouter();
-  const className = (link: string) =>
-    pathname === link ? styles.nav__linkActive : styles.nav__link;
 
 
   return (
   return (
     <footer id='footer' className={styles.footer}>
     <footer id='footer' className={styles.footer}>
       <nav aria-label='primary navigation' className={styles.nav}>
       <nav aria-label='primary navigation' className={styles.nav}>
         <ul className={styles.list}>
         <ul className={styles.list}>
-          <li className={styles.nav__item}>
-            <Link href='/about'>
-              <a className={className('/about')}>About</a>
-            </Link>
-          </li>
-          <li className={styles.nav__item}>
-            <Link href='/find'>
-              <a className={className('/find')}>Search</a>
-            </Link>
-          </li>
-          <li className={styles.nav__item}>
-            <Link href='/privacy'>
-              <a className={className('/privacy')}>Privacy</a>
-            </Link>
-          </li>
-          <li className={styles.nav__item}>
-            <Link href='/contact'>
-              <a className={className('/contact')}>Contact</a>
-            </Link>
-          </li>
+          {links.map(link => (
+            <li className={styles.nav__item} key={link.path}>
+              <Link href={link.path}>
+                <a
+                  className={styles.nav__link}
+                  aria-current={pathname === link.path ? 'page' : undefined}
+                >
+                  {link.text}
+                </a>
+              </Link>
+            </li>
+          ))}
           <li className={styles.nav__item}>
           <li className={styles.nav__item}>
             <a href='#' className={styles.nav__link}>
             <a href='#' className={styles.nav__link}>
               Back to top
               Back to top
@@ -39,7 +36,7 @@ const Footer = () => {
         </ul>
         </ul>
       </nav>
       </nav>
       <p className={styles.licence}>
       <p className={styles.licence}>
-        Licensed under&nbsp;
+        Licensed under{' '}
         <a
         <a
           className={styles.nav__link}
           className={styles.nav__link}
           href='https://www.gnu.org/licenses/agpl-3.0-standalone.html'
           href='https://www.gnu.org/licenses/agpl-3.0-standalone.html'

+ 3 - 3
src/layouts/Layout.tsx

@@ -1,10 +1,10 @@
-import React from 'react';
+import { ReactNode } from 'react';
 import Footer from './Footer';
 import Footer from './Footer';
 import Header from './Header';
 import Header from './Header';
 
 
 type Props = {
 type Props = {
-  full?: boolean;
-  children: React.ReactNode;
+  full?: true;
+  children: ReactNode;
   className: string;
   className: string;
 };
 };
 
 

+ 13 - 24
src/pages/title/[titleId]/index.tsx

@@ -1,23 +1,20 @@
-// external
-import { GetServerSideProps, GetStaticProps, GetStaticPaths } from 'next';
-import Head from 'next/head';
+import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
 import Meta from 'src/components/meta/Meta';
 import Meta from 'src/components/meta/Meta';
 import Layout from 'src/layouts/Layout';
 import Layout from 'src/layouts/Layout';
 import ErrorInfo from 'src/components/error/ErrorInfo';
 import ErrorInfo from 'src/components/error/ErrorInfo';
-// prettier-ignore
-import { Basic, Cast, DidYouKnow, Info, Media, MoreLikeThis, Reviews } from 'src/components/title';
+import Media from 'src/components/media/Media';
+import { Basic, Cast, DidYouKnow, Info, MoreLikeThis, Reviews } from 'src/components/title';
 import Title from 'src/interfaces/shared/title';
 import Title from 'src/interfaces/shared/title';
 import { AppError } from 'src/interfaces/shared/error';
 import { AppError } from 'src/interfaces/shared/error';
 import title from 'src/utils/fetchers/title';
 import title from 'src/utils/fetchers/title';
 import { getProxiedIMDbImgUrl } from 'src/utils/helpers';
 import { getProxiedIMDbImgUrl } from 'src/utils/helpers';
 import styles from 'src/styles/modules/pages/title/title.module.scss';
 import styles from 'src/styles/modules/pages/title/title.module.scss';
 
 
-type Props = { data: Title; error: null } | { error: AppError; data: null };
+type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
 
 
 // TO-DO: make a wrapper page component to display errors, if present in props
 // TO-DO: make a wrapper page component to display errors, if present in props
 const TitleInfo = ({ data, error }: Props) => {
 const TitleInfo = ({ data, error }: Props) => {
-  if (error)
-    return <ErrorInfo message={error.message} statusCode={error.statusCode} />;
+  if (error) return <ErrorInfo message={error.message} statusCode={error.statusCode} />;
 
 
   const info = {
   const info = {
     meta: data.meta,
     meta: data.meta,
@@ -31,21 +28,10 @@ const TitleInfo = ({ data, error }: Props) => {
   return (
   return (
     <>
     <>
       <Meta
       <Meta
-        title={`${data.basic.title} (${
-          data.basic.releaseYear?.start || data.basic.type.name
-        })`}
-        description={data.basic.plot || undefined}
+        title={`${data.basic.title} (${data.basic.releaseYear?.start || data.basic.type.name})`}
+        description={data.basic.plot ?? undefined}
+        imgUrl={data.basic.poster?.url && getProxiedIMDbImgUrl(data.basic.poster.url)}
       />
       />
-      <Head>
-        <meta
-          title='og:image'
-          content={
-            data.basic.poster?.url
-              ? getProxiedIMDbImgUrl(data.basic.poster?.url)
-              : '/icon-512.png'
-          }
-        />
-      </Head>
       <Layout className={styles.title}>
       <Layout className={styles.title}>
         <Basic data={data.basic} className={styles.basic} />
         <Basic data={data.basic} className={styles.basic} />
         <Media className={styles.media} media={data.media} />
         <Media className={styles.media} media={data.media} />
@@ -62,8 +48,11 @@ const TitleInfo = ({ data, error }: Props) => {
 };
 };
 
 
 // TO-DO: make a getServerSideProps wrapper for handling errors
 // TO-DO: make a getServerSideProps wrapper for handling errors
-export const getServerSideProps: GetServerSideProps = async ctx => {
-  const titleId = ctx.params!.titleId as string;
+type Data = { data: Title; error: null } | { error: AppError; data: null };
+type Params = { titleId: string };
+
+export const getServerSideProps: GetServerSideProps<Data, Params> = async ctx => {
+  const titleId = ctx.params!.titleId;
 
 
   try {
   try {
     const data = await title(titleId);
     const data = await title(titleId);

+ 5 - 5
src/styles/abstracts/variables/_misc.scss

@@ -21,8 +21,8 @@ $breakpoints: (
 );
 );
 
 
 // 1. colors
 // 1. colors
-$clr-primary: hsl(240, 31%, 25%);
-$clr-secondary: hsl(344, 79%, 40%);
-$clr-tertiary: hsl(176, 43%, 46%);
-$clr-quatenary: hsl(204, 4%, 23%);
-$clr-quintenary: hsl(0, 0%, 100%);
+// $clr-primary: hsl(240, 31%, 25%);
+// $clr-secondary: hsl(344, 79%, 40%);
+// $clr-tertiary: hsl(176, 43%, 46%);
+// $clr-quatenary: hsl(204, 4%, 23%);
+// $clr-quintenary: hsl(0, 0%, 100%);

+ 2 - 6
src/styles/abstracts/variables/_themes.scss

@@ -22,17 +22,13 @@ $_light: (
   // 4.2 for borders, primarily
   // 4.2 for borders, primarily
   fill-muted: hsl(0, 0%, 80%),
   fill-muted: hsl(0, 0%, 80%),
   // shadows on cards
   // shadows on cards
-  shadow: 0 0 1rem hsla(0, 0%, 0%, 0.2),
+  shadow: 0 0 0.5em hsla(0, 0%, 0%, 0.2),
   // keyboard, focus hightlight
   // keyboard, focus hightlight
   highlight: hsl(176, 43%, 46%),
   highlight: hsl(176, 43%, 46%),
   // for gradient behind hero text on about page.
   // for gradient behind hero text on about page.
   gradient:
   gradient:
     (
     (
-      radial-gradient(
-        at 23% 32%,
-        hsla(344, 79%, 40%, 0.15) 0px,
-        transparent 70%
-      ),
+      radial-gradient(at 23% 32%, hsla(344, 79%, 40%, 0.15) 0px, transparent 70%),
       radial-gradient(at 72% 55%, hsla(344, 79%, 40%, 0.2) 0px, transparent 50%)
       radial-gradient(at 72% 55%, hsla(344, 79%, 40%, 0.2) 0px, transparent 50%)
     ),
     ),
   // changes color of native html elemnts, either 'light' or 'dark' must be set.
   // changes color of native html elemnts, either 'light' or 'dark' must be set.

+ 97 - 0
src/styles/modules/components/card/card-basic.module.scss

@@ -0,0 +1,97 @@
+@use '../../../abstracts' as helper;
+
+.container {
+  margin-inline: auto;
+  display: grid;
+
+  grid-template-columns: minmax(25rem, 30rem) 1fr;
+
+  @include helper.bp('bp-900') {
+    grid-template-columns: none;
+    grid-template-rows: 30rem min-content;
+  }
+
+  @include helper.bp('bp-700') {
+    grid-template-rows: 25rem min-content;
+  }
+}
+
+.imageContainer {
+  display: flex; // for bringing out image__NA out of blur
+
+  position: relative;
+  height: auto;
+  width: auto;
+  overflow: hidden;
+
+  background-size: cover;
+  background-position: top;
+  place-items: center;
+
+  @include helper.bp('bp-900') {
+    padding: var(--spacer-2);
+    isolation: isolate;
+
+    // for adding layer of color on top of background image
+    &::after {
+      content: '';
+      position: absolute;
+      inset: 0;
+      background: linear-gradient(
+        to top,
+        var(--clr-bg-accent) 10%,
+        transparent
+      );
+      backdrop-filter: blur(8px);
+    }
+  }
+}
+
+.image {
+  object-fit: cover;
+  object-position: center;
+
+  @include helper.bp('bp-900') {
+    z-index: 1;
+    object-fit: contain;
+
+    outline: 3px solid var(--clr-fill);
+    outline-offset: 5px;
+
+    max-height: 100%;
+    margin: auto;
+
+    // overrriding nex/future/image defaults
+    height: initial !important;
+    width: initial !important;
+    position: relative !important;
+  }
+}
+
+.imageNA {
+  z-index: 1;
+  fill: var(--clr-fill-muted);
+}
+
+.info {
+  padding: var(--spacer-2) var(--spacer-4);
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacer-2);
+
+  @include helper.bp('bp-900') {
+    // text-align: center;
+    // align-items: center;
+  }
+  @include helper.bp('bp-450') {
+    gap: var(--spacer-1);
+  }
+}
+
+.title {
+  line-height: 1;
+
+  @include helper.bp('bp-900') {
+    text-align: center;
+  }
+}

+ 44 - 0
src/styles/modules/components/card/card-cast.module.scss

@@ -0,0 +1,44 @@
+.item {
+  height: 100%;
+  display: grid;
+  grid-template-rows: minmax(auto, 65%) auto;
+
+  text-decoration: none;
+  color: currentColor;
+}
+
+.imgContainer {
+  justify-self: stretch;
+  position: relative;
+
+  // for icon when image is unavailable
+  display: grid;
+  place-items: center;
+}
+
+.img {
+  height: 100%;
+  object-fit: cover;
+}
+
+.imgNA {
+  fill: var(--clr-fill-muted);
+  height: 40%;
+}
+
+.textContainer {
+  display: grid;
+  gap: var(--spacer-1);
+  padding: var(--spacer-1);
+  text-align: center;
+  justify-items: center;
+  align-content: start;
+}
+
+.name {
+  font-size: 1.2em;
+}
+
+.role {
+  font-size: .95em;
+}

+ 62 - 0
src/styles/modules/components/card/card-result.module.scss

@@ -0,0 +1,62 @@
+@use '../../../abstracts' as helper;
+
+.item {
+  --width: 10rem;
+  --height: var(--width);
+
+  display: grid;
+  grid-template-columns: var(--width) auto;
+
+  text-decoration: none;
+  color: inherit;
+
+  @include helper.bp('bp-450') {
+    --height: 15rem;
+    grid-template-columns: auto;
+  }
+}
+
+.sansImage {
+  grid-template-columns:  auto;
+  padding: var(--spacer-1);
+  
+  .imgContainer {
+    display: none;
+  }
+}
+
+.imgContainer {
+  display: grid;
+  place-items: center;
+  min-height: var(--height);
+  position: relative;
+}
+
+.img {
+  object-fit: cover;
+  object-position: center 25%; // most of the time, person's face is visible at 1/4 of height in a potrait image.
+}
+
+.imgNA {
+  width: 80%;
+  fill: var(--clr-fill-muted);
+}
+
+.info {
+  display: grid;
+  padding: var(--spacer-3);
+  gap: var(--spacer-0);
+
+  @include helper.bp('bp-450') {
+    padding: var(--spacer-1);
+  }
+
+  & :empty {
+    display: none;
+  }
+}
+
+.heading {
+  font-size: var(--fs-4);
+  text-decoration: none;
+}

+ 56 - 0
src/styles/modules/components/card/card-title.module.scss

@@ -0,0 +1,56 @@
+.item {
+  height: 100%;
+  display: grid;
+  grid-template-rows: minmax(auto, 65%) auto;
+
+  text-decoration: none;
+  color: currentColor;
+}
+
+.imgContainer {
+  justify-self: stretch;
+  position: relative;
+
+  // for icon when image is unavailable
+  display: grid;
+  place-items: center;
+}
+
+.img {
+  height: 100%;
+  object-fit: cover;
+}
+
+.imgNA {
+  fill: var(--clr-fill-muted);
+  height: 40%;
+}
+
+.textContainer {
+  display: grid;
+  gap: var(--spacer-1);
+  padding: var(--spacer-1);
+  text-align: center;
+  justify-items: center;
+  align-content: start;
+}
+
+.name {
+  font-size: 1.2em;
+}
+
+.rating {
+  display: flex;
+  align-items: center;
+  gap: var(--spacer-0);
+  line-height: 1;
+  flex-wrap: wrap;
+  justify-content: center;
+}
+
+.ratingIcon {
+  --dim: 1em;
+  height: var(--dim);
+  width: var(--dim);
+  fill: var(--clr-fill);
+}

+ 11 - 0
src/styles/modules/components/card/card.module.scss

@@ -0,0 +1,11 @@
+.card {
+  overflow: hidden;
+  border-radius: 5px;
+  background-color: var(--clr-bg-accent);
+  box-shadow: var(--clr-shadow);
+}
+
+.hoverable:hover,
+.hoverable:focus-within {
+  background-color: var(--clr-bg-muted);
+}

+ 0 - 13
src/styles/modules/components/find/company.module.scss

@@ -1,13 +0,0 @@
-.company {
-  background: var(--clr-bg-accent);
-  box-shadow: var(--clr-shadow);
-  border-radius: 5px;
-  display: grid;
-  padding: var(--spacer-3);
-  gap: var(--spacer-0);
-}
-
-.heading {
-  font-size: var(--fs-4);
-  text-decoration: none;
-}

+ 0 - 13
src/styles/modules/components/find/keyword.module.scss

@@ -1,13 +0,0 @@
-.keyword {
-  background: var(--clr-bg-accent);
-  box-shadow: var(--clr-shadow);
-  border-radius: 5px;
-  display: grid;
-  padding: var(--spacer-3);
-  gap: var(--spacer-0);
-}
-
-.heading {
-  font-size: var(--fs-4);
-  text-decoration: none;
-}

+ 1 - 62
src/styles/modules/components/find/person.module.scss

@@ -1,57 +1,4 @@
-@use '../../../abstracts' as helper;
-
-.person {
-  --width: 10rem;
-  --height: var(--width);
-
-  background: var(--clr-bg-accent);
-  box-shadow: var(--clr-shadow);
-  border-radius: 5px;
-  overflow: hidden; // for background image
-  display: grid;
-  grid-template-columns: var(--width) auto;
-  
-  @include helper.bp('bp-450') {
-    --height: 15rem;
-    grid-template-columns: auto;
-  }
-}
-
-.imgContainer {
-  display: grid;
-  place-items: center;
-  min-height: var(--height);
-}
-
-.img {
-  object-fit: cover;
-  object-position: center 25%; // most of the time, person's face is visible at 1/4 of height in a potrait image.
-
-}
-
-.imgNA {
-  width: 80%;
-  fill: var(--clr-fill-muted);
-}
-
-.info {
-  display: grid;
-  padding: var(--spacer-3);
-  gap: var(--spacer-0);
-
-  @include helper.bp('bp-450') {
-    padding: var(--spacer-1);
-  }
-}
-
-.heading {
-  font-size: var(--fs-4);
-  text-decoration: none;
-}
-
-
-
-.basicInfo, .seriesInfo {
+.basicInfo {
   display: flex;
   display: flex;
   list-style: none;
   list-style: none;
   flex-wrap: wrap;
   flex-wrap: wrap;
@@ -64,11 +11,3 @@
     font-size: var(--fs-5);
     font-size: var(--fs-5);
   }
   }
 }
 }
-
-
-.stars  {
-
-  span {
-   font-weight: var(--fw-bold); 
-  }
-}

+ 4 - 4
src/styles/modules/components/find/results.module.scss

@@ -10,7 +10,10 @@
   }
   }
 }
 }
 
 
-.titles, .people, .companies, .keywords {
+.titles,
+.people,
+.companies,
+.keywords {
   display: grid;
   display: grid;
   gap: var(--spacer-2);
   gap: var(--spacer-2);
 
 
@@ -18,8 +21,5 @@
     padding: var(--spacer-2);
     padding: var(--spacer-2);
     display: grid;
     display: grid;
     gap: var(--spacer-4);
     gap: var(--spacer-4);
-    // justify-self: start;
-
-
   }
   }
 }
 }

+ 1 - 53
src/styles/modules/components/find/title.module.scss

@@ -1,57 +1,5 @@
 @use '../../../abstracts' as helper;
 @use '../../../abstracts' as helper;
 
 
-.title {
-  --width: 10rem;
-  --height: 10rem;
-
-  background: var(--clr-bg-accent);
-  box-shadow: var(--clr-shadow);
-  border-radius: 5px;
-  overflow: hidden; // for background image
-  display: grid;
-  grid-template-columns: var(--width) auto;
-
-  @include helper.bp('bp-450') {
-    --height: 15rem;
-    grid-template-columns: auto;
-  }
-}
-
-.imgContainer {
-  min-height: var(--height);
-
-  display: grid;
-  place-items: center;
-  position: relative;
-}
-
-.img {
-  object-fit: cover;
-
-  @include helper.bp('bp-450') {
-    object-position: center 25%;
-  }
-}
-
-.imgNA {
-  width: 80%;
-  fill: var(--clr-fill-muted);
-}
-
-.info {
-  display: grid;
-  gap: var(--spacer-0);
-  padding: var(--spacer-3);
-
-  @include helper.bp('bp-450') {
-    padding: var(--spacer-1);
-  }
-}
-
-.heading {
-  font-size: var(--fs-4);
-  text-decoration: none;
-}
 
 
 .basicInfo,
 .basicInfo,
 .seriesInfo {
 .seriesInfo {
@@ -59,7 +7,7 @@
   list-style: none;
   list-style: none;
   flex-wrap: wrap;
   flex-wrap: wrap;
 
 
-  & * + ::before {
+  & :not(:last-child)::after {
     content: '\00b7';
     content: '\00b7';
     padding-inline: var(--spacer-1);
     padding-inline: var(--spacer-1);
     font-weight: 900;
     font-weight: 900;

+ 0 - 0
src/styles/modules/components/title/media.module.scss → src/styles/modules/components/media/media.module.scss


+ 0 - 92
src/styles/modules/components/title/basic.module.scss

@@ -1,97 +1,5 @@
 @use '../../../abstracts' as helper;
 @use '../../../abstracts' as helper;
 
 
-.container {
-  margin-inline: auto;
-  background: var(--clr-bg-accent);
-  box-shadow: var(--clr-shadow);
-  border-radius: 5px;
-  overflow: hidden; // for background image
-  display: grid;
-
-  grid-template-columns: minmax(25rem, 30rem) 1fr;
-
-  @include helper.bp('bp-900') {
-    grid-template-columns: none;
-    grid-template-rows: 30rem min-content;
-  }
-
-  @include helper.bp('bp-700') {
-    grid-template-rows: 25rem min-content;
-  }
-}
-
-.imageContainer {
-  display: flex; // for bringing out image__NA out of blur
-
-  position: relative;
-  height: auto;
-  width: auto;
-  overflow: hidden;
-
-  background-size: cover;
-  background-position: top;
-  place-items: center;
-
-  @include helper.bp('bp-900') {
-    padding: var(--spacer-2);
-    isolation: isolate;
-
-    // for adding layer of color on top of background image
-    &::after {
-      content: '';
-      position: absolute;
-      inset: 0;
-      background: linear-gradient(
-        to top,
-        var(--clr-bg-accent) 10%,
-        transparent
-      );
-      backdrop-filter: blur(8px);
-    }
-  }
-}
-
-.image {
-  object-fit: cover;
-  object-position: center;
-
-  @include helper.bp('bp-900') {
-    z-index: 1;
-    object-fit: contain;
-
-    outline: 3px solid var(--clr-fill);
-    outline-offset: 5px;
-
-    max-height: 100%;
-    margin: auto;
-
-    // overrriding nex/future/image defaults
-    height: initial !important;
-    width: initial !important;
-    position: relative !important;
-  }
-
-  &__NA {
-    z-index: 1;
-    fill: var(--clr-fill-muted);
-  }
-}
-
-.info {
-  padding: var(--spacer-2) var(--spacer-4);
-  display: flex;
-  flex-direction: column;
-  gap: var(--spacer-2);
-
-  @include helper.bp('bp-900') {
-    text-align: center;
-    align-items: center;
-  }
-  @include helper.bp('bp-450') {
-    gap: var(--spacer-1);
-  }
-}
-
 .title {
 .title {
   line-height: 1;
   line-height: 1;
 }
 }

+ 0 - 50
src/styles/modules/components/title/cast.module.scss

@@ -24,53 +24,3 @@
     --min-height: 30rem;
     --min-height: 30rem;
   }
   }
 }
 }
-
-.member {
-  height: 100%;
-  display: grid;
-  grid-template-rows: minmax(auto, 70%) min-content auto;
-  justify-items: center;
-  text-align: center;
-  font-size: var(--fs-5);
-
-  overflow: hidden;
-  border-radius: 5px;
-  box-shadow: var(--clr-shadow);
-  background-color: var(--clr-bg-accent);
-
-  &__imgContainer {
-    justify-self: stretch;
-    position: relative;
-
-    // for icon when image is unavailable
-    display: grid;
-    place-items: center;
-  }
-  &__img {
-    height: 100%;
-    object-fit: cover;
-  }
-
-  &__imgNA {
-    fill: var(--clr-fill-muted);
-    height: 40%;
-  }
-
-  &__textContainer {
-    display: grid;
-    gap: var(--spacer-0);
-    padding: var(--spacer-0);
-    // place-content: center;
-    text-align: center;
-    justify-items: center;
-    align-content: start;
-  }
-
-  &__name {
-    @include helper.prettify-link(var(--clr-link));
-  }
-
-  &__role {
-    font-size: 0.9em;
-  }
-}

+ 0 - 74
src/styles/modules/components/title/more-like-this.module.scss

@@ -29,77 +29,3 @@
     min-height: 37rem;
     min-height: 37rem;
   }
   }
 }
 }
-
-.item {
-  overflow: hidden;
-  border-radius: 5px;
-  box-shadow: var(--clr-shadow);
-
-  height: 100%;
-  display: grid;
-  grid-template-rows: minmax(auto, 70%) auto;
-
-  background-color: var(--clr-bg-accent);
-  text-decoration: none;
-  color: currentColor;
-
-  &__imgContainer {
-    justify-self: stretch;
-    position: relative;
-
-    // for icon when image is unavailable
-    display: grid;
-    place-items: center;
-  }
-  &__textContainer {
-    display: grid;
-    gap: var(--spacer-1);
-    padding: var(--spacer-1);
-    // place-content: center;
-    text-align: center;
-    justify-items: center;
-    align-content: start;
-  }
-
-  &__img {
-    height: 100%;
-    object-fit: cover;
-  }
-
-  &__imgNA {
-    fill: var(--clr-fill-muted);
-    height: 40%;
-    // vertical-align: center;
-  }
-
-  &__heading {
-  }
-
-  &__genres {
-  }
-
-  &__rating {
-    // font-size: 0.9em;
-    display: flex;
-    align-items: center;
-    gap: var(--spacer-0);
-    line-height: 1;
-
-    flex-wrap: wrap;
-    justify-content: center;
-  }
-
-  &__ratingNum {
-  }
-
-  &__ratingIcon {
-    --dim: 1em;
-    height: var(--dim);
-    width: var(--dim);
-    fill: var(--clr-fill);
-  }
-
-  &:hover {
-    background-color: var(--clr-bg-muted);
-  }
-}

+ 3 - 3
src/styles/modules/layout/footer.module.scss

@@ -23,10 +23,10 @@
 
 
   &__link {
   &__link {
     @include helper.prettify-link(var(--clr-link));
     @include helper.prettify-link(var(--clr-link));
-  }
 
 
-  &__linkActive {
-    @include helper.prettify-link(var(--clr-link), $animate: false);
+    &[aria-current] {
+      @include helper.prettify-link(var(--clr-link), $animate: false);
+    }
   }
   }
 }
 }
 
 

+ 7 - 3
src/utils/redis.ts

@@ -1,11 +1,15 @@
+/* eslint-disable no-unused-vars */
 import Redis from 'ioredis';
 import Redis from 'ioredis';
 
 
 const redisUrl = process.env.REDIS_URL;
 const redisUrl = process.env.REDIS_URL;
 const toUseRedis = process.env.USE_REDIS === 'true';
 const toUseRedis = process.env.USE_REDIS === 'true';
 
 
-let redis: Redis | null;
+const stub: Pick<Redis, 'get' | 'setex' | 'getBuffer'> = {
+  get: async key => Promise.resolve(null),
+  setex: async (key, seconds, value) => Promise.resolve('OK'),
+  getBuffer: (key, callback) => Promise.resolve(null),
+};
 
 
-if (toUseRedis && redisUrl) redis = new Redis(redisUrl);
-else redis = null;
+const redis = toUseRedis && redisUrl ? new Redis(redisUrl) : stub;
 
 
 export default redis;
 export default redis;