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:
zyachel
2022-12-31 22:21:36 +05:30
parent 81eaf2fd5e
commit 0cff34a766
25 changed files with 1191 additions and 60 deletions

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;