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
This commit is contained in:
22
src/components/find/Company.tsx
Normal file
22
src/components/find/Company.tsx
Normal file
@ -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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Company;
|
21
src/components/find/Keyword.tsx
Normal file
21
src/components/find/Keyword.tsx
Normal file
@ -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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Keyword;
|
45
src/components/find/Person.tsx
Normal file
45
src/components/find/Person.tsx
Normal file
@ -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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Person;
|
60
src/components/find/Title.tsx
Normal file
60
src/components/find/Title.tsx
Normal file
@ -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 (
|
||||
<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>}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Title;
|
95
src/components/find/index.tsx
Normal file
95
src/components/find/index.tsx
Normal file
@ -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 (
|
||||
<h1 className={`heading heading__primary ${className}`}>
|
||||
No results found
|
||||
</h1>
|
||||
);
|
||||
|
||||
const { titles, people, keywords, companies, meta } = results!;
|
||||
const titlesSectionHeading = getResTitleTypeHeading(
|
||||
meta.type,
|
||||
meta.titleType
|
||||
);
|
||||
|
||||
return (
|
||||
<article className={`${className} ${styles.results}`}>
|
||||
<h1 className="heading heading__primary">Results for '{title}'</h1>
|
||||
<div className={styles.results__list}>
|
||||
{!!titles.length && (
|
||||
<section className={styles.titles}>
|
||||
<h2 className="heading heading__secondary">
|
||||
{titlesSectionHeading}
|
||||
</h2>
|
||||
<ul className={styles.titles__list}>
|
||||
{titles.map(title => (
|
||||
<Title title={title} key={title.id} />
|
||||
))}
|
||||
</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;
|
124
src/components/forms/find/index.tsx
Normal file
124
src/components/forms/find/index.tsx
Normal file
@ -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;
|
83
src/interfaces/misc/rawFind.ts
Normal file
83
src/interfaces/misc/rawFind.ts
Normal file
@ -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']
|
||||
// }}}
|
||||
// }
|
28
src/interfaces/shared/search.ts
Normal file
28
src/interfaces/shared/search.ts
Normal file
@ -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'];
|
@ -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>
|
||||
|
@ -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'
|
||||
|
77
src/pages/find/index.tsx
Normal file
77
src/pages/find/index.tsx
Normal file
@ -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;
|
13
src/styles/modules/components/find/company.module.scss
Normal file
13
src/styles/modules/components/find/company.module.scss
Normal file
@ -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;
|
||||
}
|
13
src/styles/modules/components/find/keyword.module.scss
Normal file
13
src/styles/modules/components/find/keyword.module.scss
Normal file
@ -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;
|
||||
}
|
74
src/styles/modules/components/find/person.module.scss
Normal file
74
src/styles/modules/components/find/person.module.scss
Normal file
@ -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);
|
||||
}
|
||||
}
|
25
src/styles/modules/components/find/results.module.scss
Normal file
25
src/styles/modules/components/find/results.module.scss
Normal file
@ -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;
|
||||
|
||||
|
||||
}
|
||||
}
|
75
src/styles/modules/components/find/title.module.scss
Normal file
75
src/styles/modules/components/find/title.module.scss
Normal file
@ -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);
|
||||
}
|
||||
}
|
148
src/styles/modules/components/form/find.module.scss
Normal file
148
src/styles/modules/components/form/find.module.scss
Normal file
@ -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)
|
||||
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
44
src/styles/modules/pages/find/find.module.scss
Normal file
44
src/styles/modules/pages/find/find.module.scss
Normal file
@ -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;
|
||||
}
|
71
src/utils/cleaners/find.ts
Normal file
71
src/utils/cleaners/find.ts
Normal file
@ -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;
|
36
src/utils/constants/find.ts
Normal file
36
src/utils/constants/find.ts
Normal file
@ -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;
|
27
src/utils/fetchers/basicSearch.ts
Normal file
27
src/utils/fetchers/basicSearch.ts
Normal file
@ -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;
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
Reference in New Issue
Block a user