From 0cff34a766b09ba17be2a89f6290889dbf225436 Mon Sep 17 00:00:00 2001 From: zyachel Date: Sat, 31 Dec 2022 22:21:36 +0530 Subject: [PATCH] feat(search): add basic search functionality this commit adds basic search feature. fix: https://codeberg.org/zyachel/libremdb/issues/9, https://github.com/zyachel/libremdb/issues/10 --- next.config.mjs | 24 ++- public/svg/sprite.svg | 37 +---- src/components/find/Company.tsx | 22 +++ src/components/find/Keyword.tsx | 21 +++ src/components/find/Person.tsx | 45 ++++++ src/components/find/Title.tsx | 60 +++++++ src/components/find/index.tsx | 95 +++++++++++ src/components/forms/find/index.tsx | 124 +++++++++++++++ src/interfaces/misc/rawFind.ts | 83 ++++++++++ src/interfaces/shared/search.ts | 28 ++++ src/layouts/Footer.tsx | 5 + src/layouts/Header.tsx | 49 ++++-- src/pages/find/index.tsx | 77 +++++++++ .../components/find/company.module.scss | 13 ++ .../components/find/keyword.module.scss | 13 ++ .../components/find/person.module.scss | 74 +++++++++ .../components/find/results.module.scss | 25 +++ .../modules/components/find/title.module.scss | 75 +++++++++ .../modules/components/form/find.module.scss | 148 ++++++++++++++++++ src/styles/modules/layout/header.module.scss | 15 +- .../modules/pages/find/find.module.scss | 44 ++++++ src/utils/cleaners/find.ts | 71 +++++++++ src/utils/constants/find.ts | 36 +++++ src/utils/fetchers/basicSearch.ts | 27 ++++ src/utils/helpers.ts | 40 ++++- 25 files changed, 1191 insertions(+), 60 deletions(-) create mode 100644 src/components/find/Company.tsx create mode 100644 src/components/find/Keyword.tsx create mode 100644 src/components/find/Person.tsx create mode 100644 src/components/find/Title.tsx create mode 100644 src/components/find/index.tsx create mode 100644 src/components/forms/find/index.tsx create mode 100644 src/interfaces/misc/rawFind.ts create mode 100644 src/interfaces/shared/search.ts create mode 100644 src/pages/find/index.tsx create mode 100644 src/styles/modules/components/find/company.module.scss create mode 100644 src/styles/modules/components/find/keyword.module.scss create mode 100644 src/styles/modules/components/find/person.module.scss create mode 100644 src/styles/modules/components/find/results.module.scss create mode 100644 src/styles/modules/components/find/title.module.scss create mode 100644 src/styles/modules/components/form/find.module.scss create mode 100644 src/styles/modules/pages/find/find.module.scss create mode 100644 src/utils/cleaners/find.ts create mode 100644 src/utils/constants/find.ts create mode 100644 src/utils/fetchers/basicSearch.ts diff --git a/next.config.mjs b/next.config.mjs index 3001dee..ff6f94a 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -2,14 +2,21 @@ const nextConfig = { reactStrictMode: true, swcMinify: true, - async redirects() { - return [ - { - source: '/', - destination: '/about', - permanent: true, - }, - ]; + async rewrites() { + return { + afterFiles: [ + { + source: '/', + destination: '/find', + }, + ], + fallback: [ + { + source: '/:path*', + destination: '/404', + }, + ], + }; }, images: { domains: ['m.media-amazon.com'], @@ -20,6 +27,7 @@ const nextConfig = { }, isrMemoryCacheSize: 20 * 1024 * 1024, }, + poweredByHeader: false, }; export default nextConfig; diff --git a/public/svg/sprite.svg b/public/svg/sprite.svg index 954d6fc..d46a088 100644 --- a/public/svg/sprite.svg +++ b/public/svg/sprite.svg @@ -15,42 +15,19 @@ - - - - - - - - - - - - - - - - - - + - - - - - - @@ -60,14 +37,10 @@ - - + + - - + + - - - - \ No newline at end of file diff --git a/src/components/find/Company.tsx b/src/components/find/Company.tsx new file mode 100644 index 0000000..4f86e7f --- /dev/null +++ b/src/components/find/Company.tsx @@ -0,0 +1,22 @@ +import { Companies } from '../../interfaces/shared/search'; +import Link from 'next/link'; + +import styles from '../../styles/modules/components/find/company.module.scss'; + +type Props = { + company: Companies[0]; +}; + +const Company = ({ company }: Props) => { + return ( +
  • + + {company.name} + + {company.country &&

    {company.country}

    } + {!!company.type &&

    {company.type}

    } +
  • + ); +}; + +export default Company; diff --git a/src/components/find/Keyword.tsx b/src/components/find/Keyword.tsx new file mode 100644 index 0000000..e9bfac6 --- /dev/null +++ b/src/components/find/Keyword.tsx @@ -0,0 +1,21 @@ +import { Keywords } from '../../interfaces/shared/search'; +import Link from 'next/link'; + +import styles from '../../styles/modules/components/find/keyword.module.scss'; + +type Props = { + keyword: Keywords[0]; +}; + +const Keyword = ({ keyword }: Props) => { + return ( +
  • + + {keyword.text} + + {keyword.numTitles &&

    {keyword.numTitles} titles

    } +
  • + ); +}; + +export default Keyword; diff --git a/src/components/find/Person.tsx b/src/components/find/Person.tsx new file mode 100644 index 0000000..eb8993b --- /dev/null +++ b/src/components/find/Person.tsx @@ -0,0 +1,45 @@ +import { People } from '../../interfaces/shared/search'; +import Image from 'next/future/image'; +import Link from 'next/link'; +import { modifyIMDbImg } from '../../utils/helpers'; +import styles from '../../styles/modules/components/find/person.module.scss'; + +type Props = { + person: People[0]; +}; + +const Person = ({ person }: Props) => { + return ( +
  • +
    + {person.image ? ( + {person.image.caption} + ) : ( + + + + )} +
    +
    + + {person.name} + + {person.aka &&

    {person.aka}

    } + {person.jobCateogry &&

    {person.jobCateogry}

    } + {(person.knownForTitle || person.knownInYear) && ( +
      + {person.knownForTitle &&
    • {person.knownForTitle}
    • } + {person.knownInYear &&
    • {person.knownInYear}
    • } +
    + )} +
    +
  • + ); +}; + +export default Person; diff --git a/src/components/find/Title.tsx b/src/components/find/Title.tsx new file mode 100644 index 0000000..7b4d049 --- /dev/null +++ b/src/components/find/Title.tsx @@ -0,0 +1,60 @@ +import { Titles } from '../../interfaces/shared/search'; +import Image from 'next/future/image'; +import Link from 'next/link'; +import { modifyIMDbImg } from '../../utils/helpers'; + +import styles from '../../styles/modules/components/find/title.module.scss'; + +type Props = { + title: Titles[0]; +}; + +const Title = ({ title }: Props) => { + return ( +
  • +
    + {title.image ? ( + {title.image.caption} + ) : ( + + + + )} +
    +
    + + {title.name} + +
      + {title.type &&
    • {title.type}
    • } + {title.sAndE &&
    • {title.sAndE}
    • } + {title.releaseYear &&
    • {title.releaseYear}
    • } +
    + {!!title.credits.length && ( +

    + Stars: + {title.credits.join(', ')} +

    + )} + {title.seriesId && ( +
      + {title.seriesType &&
    • {title.seriesType}
    • } +
    • + + {title.seriesName} + +
    • + {title.seriesReleaseYear &&
    • {title.seriesReleaseYear}
    • } +
    + )} +
    +
  • + ); +}; + +export default Title; diff --git a/src/components/find/index.tsx b/src/components/find/index.tsx new file mode 100644 index 0000000..9562692 --- /dev/null +++ b/src/components/find/index.tsx @@ -0,0 +1,95 @@ +import Find from '../../interfaces/shared/search'; +import Company from './Company'; +import Person from './Person'; +import Title from './Title'; + +import styles from '../../styles/modules/components/find/results.module.scss'; +import Keyword from './Keyword'; +import { getResTitleTypeHeading } from '../../utils/helpers'; + +type Props = { + results: Find | null; + className?: 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; +}; + +// MAIN COMPONENT +const Results = ({ results, className, title }: Props) => { + if (!resultsExist(results)) + return ( +

    + No results found +

    + ); + + const { titles, people, keywords, companies, meta } = results!; + const titlesSectionHeading = getResTitleTypeHeading( + meta.type, + meta.titleType + ); + + return ( +
    +

    Results for '{title}'

    +
    + {!!titles.length && ( +
    +

    + {titlesSectionHeading} +

    +
      + {titles.map(title => ( + + ))} + </ul> + </section> + )} + {!!people.length && ( + <section className={styles.people}> + <h2 className="heading heading__secondary">People</h2> + <ul className={styles.people__list}> + {people.map(person => ( + <Person person={person} key={person.id} /> + ))} + </ul> + </section> + )} + {!!companies.length && ( + <section className={styles.people}> + <h2 className="heading heading__secondary">Companies</h2> + <ul className={styles.people__list}> + {companies.map(company => ( + <Company company={company} key={company.id} /> + ))} + </ul> + </section> + )} + {!!keywords.length && ( + <section className={styles.people}> + <h2 className="heading heading__secondary">Keywords</h2> + <ul className={styles.people__list}> + {keywords.map(keyword => ( + <Keyword keyword={keyword} key={keyword.id} /> + ))} + </ul> + </section> + )} + </div> + </article> + ); +}; + +export default Results; diff --git a/src/components/forms/find/index.tsx b/src/components/forms/find/index.tsx new file mode 100644 index 0000000..2d0a8d1 --- /dev/null +++ b/src/components/forms/find/index.tsx @@ -0,0 +1,124 @@ +import { useRouter } from 'next/router'; +import { ChangeEventHandler, FormEventHandler, useRef, useState } from 'react'; +import { cleanQueryStr } from '../../../utils/helpers'; +import { resultTypes, resultTitleTypes } from '../../../utils/constants/find'; + +import styles from '../../../styles/modules/components/form/find.module.scss'; +import { QueryTypes } from '../../../interfaces/shared/search'; + +/** + * helper function to render similar radio btns. saves from boilerplate. + * @param data radio btn obj + * @param parentClass class under which radio input and label will be + * @returns JSX array of radios + */ +const renderRadioBtns = ( + data: typeof resultTypes | typeof resultTitleTypes, + parentClass: string +) => { + return data.types.map(({ name, val }) => ( + <p className={parentClass} key={val}> + <input + type="radio" + name={data.key} + id={`${data.key}:${val}`} + value={val} + className="visually-hidden" + /> + <label htmlFor={`${data.key}:${val}`}>{name}</label> + </p> + )); +}; + +type Props = { + className?: string; +}; + +// MAIN FUNCTION +const Form = ({ className }: Props) => { + const router = useRouter(); + const formRef = useRef<HTMLFormElement>(null); + const [isDisabled, setIsDisabled] = useState(false); + + // title types can't be selected unless type selected is 'title'. below is the logic for disabling/enabling titleTypes. + const typesChangeHandler: ChangeEventHandler<HTMLFieldSetElement> = e => { + const el = e.target as unknown as HTMLInputElement; // we have only radios that'll fire change event. + const value = el.value as QueryTypes; + + if (value === 'tt') setIsDisabled(false); + else setIsDisabled(true); + }; + + // preventing page refresh and instead handling submission through js + const submitHandler: FormEventHandler<HTMLFormElement> = e => { + e.preventDefault(); + + const formEl = formRef.current!; + const formData = new FormData(formEl); + const query = (formData.get('q') as string).trim(); + + const entries = [...formData.entries()] as [string, string][]; + const queryStr = cleanQueryStr(entries); + + if (query) router.push(`/find?${queryStr}`); + formEl.reset(); + }; + + return ( + <form + action="/find" + onSubmit={submitHandler} + ref={formRef} + className={`${className} ${styles.form}`} + > + <p className="heading heading__primary">Search</p> + + <p className={styles.searchbar}> + <svg + className={`icon ${styles.searchbar__icon}`} + focusable="false" + aria-hidden="true" + role="img" + > + <use href="/svg/sprite.svg#icon-search"></use> + </svg> + <input + id="searchbar" + type="search" + name="q" + placeholder="movies, people..." + className={styles.searchbar__input} + /> + <label className="visually-hidden" htmlFor="searchbar"> + Search for anything + </label> + </p> + <fieldset className={styles.types} onChange={typesChangeHandler}> + <legend className={`heading ${styles.types__heading}`}> + Filter by Type + </legend> + {renderRadioBtns(resultTypes, styles.type)} + </fieldset> + <fieldset className={styles.titleTypes} disabled={isDisabled}> + <legend className={`heading ${styles.titleTypes__heading}`}> + Filter by Title Type + </legend> + {renderRadioBtns(resultTitleTypes, styles.titleType)} + </fieldset> + <p className={styles.exact}> + <label htmlFor="exact">Exact Matches</label> + <input type="checkbox" name="exact" id="exact" value="true" /> + </p> + <div className={styles.buttons}> + <button type="reset" className={styles.button}> + Clear + </button> + <button type="submit" className={styles.button}> + Submit + </button> + </div> + </form> + ); +}; + +export default Form; diff --git a/src/interfaces/misc/rawFind.ts b/src/interfaces/misc/rawFind.ts new file mode 100644 index 0000000..682456a --- /dev/null +++ b/src/interfaces/misc/rawFind.ts @@ -0,0 +1,83 @@ +import { ResultMetaTitleTypes, ResultMetaTypes } from '../shared/search'; + +export default interface RawFind { + props: { + pageProps: { + findPageMeta: { + searchTerm: string; + includeAdult: false; + isExactMatch: boolean; + searchType?: ResultMetaTypes; + titleSearchType?: ResultMetaTitleTypes[]; + }; + nameResults: { + results: Array<{ + id: string; + displayNameText: string; + knownForJobCategory: string | 0; + knownForTitleText: string | 0; + knownForTitleYear: string | 0; + avatarImageModel?: { + url: string; + // maxHeight: number; + // maxWidth: number; + caption: string; + }; + akaName?: string; + }>; + // nextCursor?: string; + // hasExactMatches?: boolean; + }; + titleResults: { + results: Array<{ + id: string; + titleNameText: string; + titleReleaseText?: string; + titleTypeText: string; + titlePosterImageModel?: { + url: string; + // maxHeight: number; + // maxWidth: number; + caption: string; + }; + topCredits: Array<string>; + imageType: string; + seriesId?: string; + seriesNameText?: string; + seriesReleaseText?: string; + seriesTypeText?: string; + seriesSeasonText?: string; + seriesEpisodeText?: string; + }>; + // nextCursor?: string; + // hasExactMatches?: boolean; + }; + companyResults: { + results: Array<{ + id: string; + companyName: string; + countryText: string; + typeText: string | 0; + }>; + // nextCursor?: string; + // hasExactMatches?: boolean; + }; + keywordResults: { + results: Array<{ + id: string; + keywordText: string; + numTitles: number; + }>; + // nextCursor?: string; + // hasExactMatches?: boolean; + }; + resultsSectionOrder: Array<string>; + }; + }; +} + +// const x: RawFind<'tt'> = { +// props: {pageProps: {findPageMeta: { +// titleSearchType: ['MOVIE'] +// }}} +// } diff --git a/src/interfaces/shared/search.ts b/src/interfaces/shared/search.ts new file mode 100644 index 0000000..3f190bc --- /dev/null +++ b/src/interfaces/shared/search.ts @@ -0,0 +1,28 @@ +import cleanFind from '../../utils/cleaners/find'; +import { resultTitleTypes, resultTypes } from '../../utils/constants/find'; + +type BasicSearch = ReturnType<typeof cleanFind>; +export type { BasicSearch as default }; + +export type Titles = BasicSearch['titles']; +export type People = BasicSearch['people']; +export type Companies = BasicSearch['companies']; +export type Keywords = BasicSearch['keywords']; + +// q=babylon&s=tt&ttype=ft&exact=true +export type FindQueryParams = { + q: string; + exact?: 'true'; + s?: QueryTypes; + ttype?: QueryTitleTypes; +}; + +export type ResultMetaTypes = typeof resultTypes.types[number]['id'] | null; + +export type ResultMetaTitleTypes = + | typeof resultTitleTypes.types[number]['id'] + | null; + +export type QueryTypes = typeof resultTypes.types[number]['val']; + +export type QueryTitleTypes = typeof resultTitleTypes.types[number]['val']; diff --git a/src/layouts/Footer.tsx b/src/layouts/Footer.tsx index 63c1d5f..8d39879 100644 --- a/src/layouts/Footer.tsx +++ b/src/layouts/Footer.tsx @@ -18,6 +18,11 @@ const Footer: FC = () => { <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> diff --git a/src/layouts/Header.tsx b/src/layouts/Header.tsx index 8bfc735..717f367 100644 --- a/src/layouts/Header.tsx +++ b/src/layouts/Header.tsx @@ -1,31 +1,24 @@ import { ReactNode } from 'react'; -// import dynamic from 'next/dynamic'; +import { useRouter } from 'next/router'; import Link from 'next/link'; -import styles from '../styles/modules/layout/header.module.scss'; import ThemeToggler from '../components/buttons/ThemeToggler'; -// const ThemeToggler = dynamic( -// () => import('../components/buttons/ThemeToggler'), -// { ssr: false } -// ); +import styles from '../styles/modules/layout/header.module.scss'; type Props = { full?: boolean; children?: ReactNode }; const Header = (props: Props) => { + const { asPath: path } = useRouter(); + return ( <header id='header' className={`${styles.header} ${props.full ? styles.header__about : ''}`} > <div className={styles.topbar}> - <Link href='/about'> + <Link href='/'> <a aria-label='go to homepage' className={styles.logo}> - <svg - className={styles.logo__icon} - focusable='false' - role='img' - aria-hidden='true' - > + <svg className={styles.logo__icon} role='img' aria-hidden> <use href='/svg/sprite.svg#icon-logo'></use> </svg> <span className={styles.logo__text}>libremdb</span> @@ -52,7 +45,29 @@ const Header = (props: Props) => { </ul> </nav> )} - <ThemeToggler className={styles.themeToggler} /> + <div className={styles.misc}> + <a + href={`https://www.imdb.com${path}`} + target='_blank' + rel='noreferrer' + > + <span className='visually-hidden'> + View on IMDb (opens in new tab) + </span> + <svg className='icon' role='img' aria-hidden> + <use href='/svg/sprite.svg#icon-external-link'></use> + </svg> + </a> + <Link href='/find'> + <a> + <span className='visually-hidden'>Search</span> + <svg className='icon' role='img' aria-hidden> + <use href='/svg/sprite.svg#icon-search'></use> + </svg> + </a> + </Link> + <ThemeToggler className={styles.themeToggler} /> + </div> </div> {props.full && ( <div className={styles.hero}> @@ -60,15 +75,15 @@ const Header = (props: Props) => { A free & open source IMDb front-end </h1> <p className={styles.hero__more}> - inspired by projects like  + inspired by projects like{' '} <a href='https://codeberg.org/teddit/teddit' className='link'> teddit </a> - ,  + ,{' '} <a href='https://github.com/zedeus/nitter' className='link'> nitter </a> - , and  + , and{' '} <a href='https://github.com/digitalblossom/alternative-frontends' className='link' diff --git a/src/pages/find/index.tsx b/src/pages/find/index.tsx new file mode 100644 index 0000000..76e6fd7 --- /dev/null +++ b/src/pages/find/index.tsx @@ -0,0 +1,77 @@ +import { GetServerSideProps } from 'next'; + +import Layout from '../../layouts/Layout'; +import ErrorInfo from '../../components/error/ErrorInfo'; +import Meta from '../../components/meta/Meta'; +import Results from '../../components/find'; +import basicSearch from '../../utils/fetchers/basicSearch'; +import Form from '../../components/forms/find'; + +import Find, { FindQueryParams } from '../../interfaces/shared/search'; +import { AppError } from '../../interfaces/shared/error'; +import { cleanQueryStr } from '../../utils/helpers'; + +import styles from '../../styles/modules/pages/find/find.module.scss'; + +type Props = + | { data: { title: string; results: Find }; error: null } + | { data: { title: null; results: null }; error: null } + | { data: { title: string; results: null }; error: AppError }; + +const getMetadata = (title: string | null) => ({ + title: title || 'Search', + description: title + ? `results for '${title}'` + : 'Search for anything on libremdb, a free & open source IMDb front-end', +}); + +const BasicSearch = ({ data: { title, results }, error }: Props) => { + if (error) + return <ErrorInfo message={error.message} statusCode={error.statusCode} />; + + return ( + <> + <Meta {...getMetadata(title)} /> + <Layout className={`${styles.find} ${!title && styles.find__home}`}> + {title && ( // only showing when user has searched for something + <Results results={results} title={title} className={styles.results} /> + )} + <Form className={styles.form} /> + </Layout> + </> + ); +}; + +// TODO: use generics for passing in queryParams(to components) for better type-checking. +export const getServerSideProps: GetServerSideProps = async ctx => { + // sample query str: find/?q=babylon&s=tt&ttype=ft&exact=true + const queryObj = ctx.query as FindQueryParams; + const query = queryObj.q?.trim(); + + if (!query) + return { props: { data: { title: null, results: null }, error: null } }; + + try { + const entries = Object.entries(queryObj); + const queryStr = cleanQueryStr(entries); + + const res = await basicSearch(queryStr); + + return { + props: { data: { title: query, results: res }, error: null }, + }; + } catch (error: any) { + const { message, statusCode } = error; + ctx.res.statusCode = statusCode; + ctx.res.statusMessage = message; + + return { + props: { + error: { message, statusCode }, + data: { title: query, results: null }, + }, + }; + } +}; + +export default BasicSearch; diff --git a/src/styles/modules/components/find/company.module.scss b/src/styles/modules/components/find/company.module.scss new file mode 100644 index 0000000..5e73edf --- /dev/null +++ b/src/styles/modules/components/find/company.module.scss @@ -0,0 +1,13 @@ +.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; +} \ No newline at end of file diff --git a/src/styles/modules/components/find/keyword.module.scss b/src/styles/modules/components/find/keyword.module.scss new file mode 100644 index 0000000..cc5fee1 --- /dev/null +++ b/src/styles/modules/components/find/keyword.module.scss @@ -0,0 +1,13 @@ +.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; +} \ No newline at end of file diff --git a/src/styles/modules/components/find/person.module.scss b/src/styles/modules/components/find/person.module.scss new file mode 100644 index 0000000..d1a9b96 --- /dev/null +++ b/src/styles/modules/components/find/person.module.scss @@ -0,0 +1,74 @@ +@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 { + display: flex; + list-style: none; + flex-wrap: wrap; + + & * + ::before { + content: '\00b7'; + padding-inline: var(--spacer-1); + font-weight: 900; + line-height: 0; + font-size: var(--fs-5); + } +} + + +.stars { + + span { + font-weight: var(--fw-bold); + } +} \ No newline at end of file diff --git a/src/styles/modules/components/find/results.module.scss b/src/styles/modules/components/find/results.module.scss new file mode 100644 index 0000000..10c346a --- /dev/null +++ b/src/styles/modules/components/find/results.module.scss @@ -0,0 +1,25 @@ +@use '../../../abstracts' as helper; + +.results { + display: grid; + gap: var(--spacer-2); + + &__list { + display: grid; + gap: var(--spacer-5); + } +} + +.titles, .people, .companies, .keywords { + display: grid; + gap: var(--spacer-2); + + &__list { + padding: var(--spacer-2); + display: grid; + gap: var(--spacer-4); + // justify-self: start; + + + } +} diff --git a/src/styles/modules/components/find/title.module.scss b/src/styles/modules/components/find/title.module.scss new file mode 100644 index 0000000..0fc2958 --- /dev/null +++ b/src/styles/modules/components/find/title.module.scss @@ -0,0 +1,75 @@ +@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, +.seriesInfo { + display: flex; + list-style: none; + flex-wrap: wrap; + + & * + ::before { + content: '\00b7'; + padding-inline: var(--spacer-1); + font-weight: 900; + line-height: 0; + font-size: var(--fs-5); + } +} + +.stars { + span { + font-weight: var(--fw-bold); + } +} diff --git a/src/styles/modules/components/form/find.module.scss b/src/styles/modules/components/form/find.module.scss new file mode 100644 index 0000000..6df1dfc --- /dev/null +++ b/src/styles/modules/components/form/find.module.scss @@ -0,0 +1,148 @@ +@use '../../../abstracts' as helper; + +.form { + display: grid; + gap: var(--spacer-2); + + position: sticky; + top: var(--spacer-2); + + @include helper.bp('bp-1200') { + position: initial; + } +} + +%border-styles { + border-radius: var(--spacer-1); + border: 2px solid var(--clr-fill-muted); +} + +.searchbar { + display: grid; + grid-template-columns: max-content 1fr; + gap: var(--spacer-1); + padding: var(--spacer-1); + + @extend %border-styles; + + --dim: 3rem; + + &__icon { + height: var(--dim); + width: var(--dim); + + fill: var(--clr-fill-muted); + } + + &__input { + font: inherit; + border: none; + outline: none; + caret-color: var(--clr-fill); + background: transparent; + color: var(--clr-text-accent); + + -webkit-appearance: none; // webkit sucks! + } + + // accessibility + &:focus-within { + background: var(--clr-bg-muted); + } + +} + +.types, +.titleTypes { + display: flex; + flex-wrap: wrap; + gap: var(--spacer-2); + padding: var(--spacer-2); + + @extend %border-styles; + + &__heading { + font-size: var(--fs-4); + padding-inline: var(--spacer-1); + flex: 100%; + line-height: 1; + color: var(--clr-text-muted); + } + + &:disabled { + &, * { + cursor: not-allowed; + filter: brightness(.95); + } + } +} + +.type, +.titleType { + --border-color: transparent; + position: relative; + display: inline-flex; + + label { + cursor: pointer; + padding: var(--spacer-1) var(--spacer-2); + border-radius: 5px; + color: var(--clr-text-accent); + background-color: var(--clr-bg-accent); + border: 2px solid var(--border-color); + } + + input:checked + label { + --border-color: var(--clr-text-accent); + } + + // for keyboard navigation + input:focus + label { + @include helper.focus-rules; + } + + @supports selector(:focus-visible) { + input:focus + label { + outline: none; + } + + input:focus-visible + label { + @include helper.focus-rules; + } + } +} + +.exact { + display: flex; + gap: var(--spacer-1); + justify-self: start; + align-items: center; + + label, input { + cursor: pointer; + } +} + +.buttons { + display: flex; + gap: var(--spacer-2); +} + +.button { + + --text: var(--clr-link); + + padding: var(--spacer-1) var(--spacer-2); + font: inherit; + + background: transparent; + color: var(--text); + border: 2px solid currentColor; + border-radius: 5px; + cursor: pointer; + + &[type='reset'] { + --text: var(--clr-text-muted) + + } +} \ No newline at end of file diff --git a/src/styles/modules/layout/header.module.scss b/src/styles/modules/layout/header.module.scss index 0f84cd9..8db849a 100644 --- a/src/styles/modules/layout/header.module.scss +++ b/src/styles/modules/layout/header.module.scss @@ -1,7 +1,7 @@ @use '../../abstracts' as helper; .header { - --dimension: 1.5em; // will be used for icons + --dimension: 1.6em; // will be used for icons font-size: 1.1em; @@ -67,9 +67,20 @@ } } -.themeToggler { +.misc { justify-self: end; grid-column: -2 / -1; + + display: flex; + align-items: center; + gap: var(--spacer-2); + position: relative; + + svg { + height: var(--dimension); + width: var(--dimension); + fill: var(--clr-fill); + } } .hero { diff --git a/src/styles/modules/pages/find/find.module.scss b/src/styles/modules/pages/find/find.module.scss new file mode 100644 index 0000000..a1151b7 --- /dev/null +++ b/src/styles/modules/pages/find/find.module.scss @@ -0,0 +1,44 @@ +@use '../../../abstracts' as helper; + +.find { + // major whitespace properties used on title page + --doc-whitespace: var(--spacer-8); + --comp-whitespace: var(--spacer-3); + + display: grid; + + gap: var(--doc-whitespace); + padding: var(--doc-whitespace); + align-items: start; + + grid-template-columns: repeat(5, 1fr); + grid-template-areas: 'results results results form form'; + + @include helper.bp('bp-900') { + grid-template-columns: none; + grid-template-areas: 'results' 'form'; + } + + @include helper.bp('bp-700') { + --doc-whitespace: var(--spacer-5); + } + + @include helper.bp('bp-450') { + padding: var(--spacer-3); + } + + &__home { + grid-template-columns: unset; + grid-template-areas: 'form'; + + justify-content: center; + } +} + +.results { + grid-area: results; +} + +.form { + grid-area: form; +} diff --git a/src/utils/cleaners/find.ts b/src/utils/cleaners/find.ts new file mode 100644 index 0000000..2dc8242 --- /dev/null +++ b/src/utils/cleaners/find.ts @@ -0,0 +1,71 @@ +import RawFind from '../../interfaces/misc/rawFind'; + +const formatSAndE = ( + season: string | undefined, + episode: string | undefined +) => { + if (season && season !== 'Unknown' && episode && episode !== 'Unknown') + return `S${season} E${episode}`; + return null; +}; + +const cleanFind = (rawFind: RawFind) => { + const { + props: { pageProps: d }, + } = rawFind; + + const cleanData = { + meta: { + exact: d.findPageMeta.isExactMatch, + type: d.findPageMeta.searchType || null, + titleType: d.findPageMeta.titleSearchType?.[0] || null, + }, + people: d.nameResults.results.map(person => ({ + id: person.id, + name: person.displayNameText, + aka: person.akaName || null, + jobCateogry: person.knownForJobCategory || null, + knownForTitle: person.knownForTitleText || null, + knownInYear: person.knownForTitleYear || null, + ...(person.avatarImageModel && { + image: { + url: person.avatarImageModel.url, + caption: person.avatarImageModel.caption, + }, + }), + })), + titles: d.titleResults.results.map(title => ({ + id: title.id, + name: title.titleNameText, + type: title.titleTypeText, + releaseYear: title.titleReleaseText || null, + credits: title.topCredits, + ...(title.titlePosterImageModel && { + image: { + url: title.titlePosterImageModel.url, + caption: title.titlePosterImageModel.caption, + }, + }), + seriesId: title.seriesId || null, + seriesName: title.seriesNameText || null, + seriesType: title.seriesTypeText || null, + seriesReleaseYear: title.seriesReleaseText || null, + sAndE: formatSAndE(title.seriesSeasonText, title.seriesEpisodeText), + })), + companies: d.companyResults.results.map(company => ({ + id: company.id, + name: company.companyName, + type: company.typeText, + country: company.countryText, + })), + keywords: d.keywordResults.results.map(keyword => ({ + id: keyword.id, + text: keyword.keywordText, + numTitles: keyword.numTitles, + })), + }; + + return cleanData; +}; + +export default cleanFind; diff --git a/src/utils/constants/find.ts b/src/utils/constants/find.ts new file mode 100644 index 0000000..e3e1f7b --- /dev/null +++ b/src/utils/constants/find.ts @@ -0,0 +1,36 @@ +/** + * @constant + * + * key: the key for the query that we make to fetch results + * + * name: Nice name to display on the client side + * + * val: the value that is associated with the key. also used to fetch results. + * + * **IMPORTANT**: see sample response from backend, and form submission url to better understand how these objects are used. + */ +export const resultTypes = { + types: [ + { name: 'Titles', val: 'tt', id: 'TITLE' }, + { name: 'People', val: 'nm', id: 'NAME' }, + { name: 'Companies', val: 'co', id: 'COMPANY' }, + { name: 'Keywords', val: 'kw', id: 'KEYWORD' }, + ], + key: 's', +} as const; + +/** + * same as {@link resultTypes}. + */ +export const resultTitleTypes = { + types: [ + { name: 'Movies', val: 'ft', id: 'MOVIE' }, + { name: 'TV', val: 'tv', id: 'TV' }, + { name: 'TV Episodes', val: 'ep', id: 'TV_EPISODE' }, + { name: 'Music Videos', val: 'mu', id: 'MUSIC_VIDEO' }, + { name: 'Podcasts', val: 'ps', id: 'PODCAST_SERIES' }, + { name: 'Podcast Episodes', val: 'pe', id: 'PODCAST_EPISODE' }, + { name: 'Video Games', val: 'vg', id: 'VIDEO_GAME' }, + ], + key: 'ttype', +} as const; diff --git a/src/utils/fetchers/basicSearch.ts b/src/utils/fetchers/basicSearch.ts new file mode 100644 index 0000000..317748d --- /dev/null +++ b/src/utils/fetchers/basicSearch.ts @@ -0,0 +1,27 @@ +// external deps +import * as cheerio from 'cheerio'; +// local files +import axiosInstance from '../axiosInstance'; +import { AppError } from '../helpers'; +import RawFind from '../../interfaces/misc/rawFind'; +import cleanFind from '../cleaners/find'; + +const basicSearch = async (queryStr: string) => { + try { + const res = await axiosInstance(`/find?${queryStr}`); + const $ = cheerio.load(res.data); + const rawData = $('script#__NEXT_DATA__').text(); + + const parsedRawData: RawFind = JSON.parse(rawData); + const cleanData = cleanFind(parsedRawData); + + return cleanData; + } catch (err: any) { + if (err.response?.status === 404) + throw new AppError('not found', 404, err.cause); + + throw new AppError('something went wrong', 500, err.cause); + } +}; + +export default basicSearch; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 8ffe821..be8ee04 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,3 +1,9 @@ +import { + ResultMetaTitleTypes, + ResultMetaTypes, +} from '../interfaces/shared/search'; +import { resultTitleTypes } from './constants/find'; + export const formatTime = (timeInSecs: number) => { if (!timeInSecs) return; // year, month, date, hours, minutes, seconds @@ -50,8 +56,14 @@ export const formatMoney = (num: number, cur: string) => { }).format(num); }; +const imageRegex = /https:\/\/m\.media-amazon\.com\/images\/M\/[^.]*/; + export const modifyIMDbImg = (url: string, widthInPx = 600) => { - return url.replace(/\.jpg/g, `UX${widthInPx}.jpg`); + // as match returns either array or null, returning array in case it returns null. and destructuring it right away. + const [cleanImg] = url.match(imageRegex) || []; + + if (cleanImg) return `${cleanImg}.UX${widthInPx}.jpg`; + return url; }; export const getProxiedIMDbImgUrl = (url: string) => { @@ -65,3 +77,29 @@ export const AppError = class extends Error { Error.captureStackTrace(this, AppError); } }; + +export const cleanQueryStr = ( + entries: [string, string][], + filterable = ['q', 's', 'exact', 'ttype'] +) => { + let queryStr = ''; + + entries.forEach(([key, val], i) => { + if (!val || !filterable.includes(key)) return; + queryStr += `${i > 0 ? '&' : ''}${key}=${val.trim()}`; + }); + + return queryStr; +}; + +export const getResTitleTypeHeading = ( + type: ResultMetaTypes, + titleType: ResultMetaTitleTypes +) => { + if (type !== 'TITLE') return 'Titles'; + + for (let i = 0; i < resultTitleTypes.types.length; i++) { + const el = resultTitleTypes.types[i]; + if (el.id === titleType) return el.name; + } +};