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

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

View file

@ -15,42 +15,19 @@
</symbol>
<!--miscellaneous logos-->
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" id="icon-cancel">
<path d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM64 256c0-41.4 13.3-79.68 35.68-111.1l267.4 267.4C335.7 434.7 297.4 448 256 448C150.1 448 64 361.9 64 256zM412.3 367.1L144.9 99.68C176.3 77.3 214.6 64 256 64c105.9 0 192 86.13 192 192C448 297.4 434.7 335.7 412.3 367.1z"></path>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" id="icon-code-document">
<path d="M162.1 257.8c-7.812-7.812-20.47-7.812-28.28 0l-48 48c-7.812 7.812-7.812 20.5 0 28.31l48 48C137.8 386.1 142.9 388 148 388s10.23-1.938 14.14-5.844c7.812-7.812 7.812-20.5 0-28.31L128.3 320l33.86-33.84C169.1 278.3 169.1 265.7 162.1 257.8zM365.3 93.38l-74.63-74.64C278.6 6.742 262.3 0 245.4 0H64C28.65 0 0 28.65 0 64l.0065 384c0 35.34 28.65 64 64 64H320c35.2 0 64-28.8 64-64V138.6C384 121.7 377.3 105.4 365.3 93.38zM336 448c0 8.836-7.164 16-16 16H64.02c-8.838 0-16-7.164-16-16L48 64.13c0-8.836 7.164-16 16-16h160L224 128c0 17.67 14.33 32 32 32h79.1V448zM221.9 257.8c-7.812 7.812-7.812 20.5 0 28.31L255.7 320l-33.86 33.84c-7.812 7.812-7.812 20.5 0 28.31C225.8 386.1 230.9 388 236 388s10.23-1.938 14.14-5.844l48-48c7.812-7.812 7.812-20.5 0-28.31l-48-48C242.3 250 229.7 250 221.9 257.8z"></path>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" id="icon-computer-home">
<path d="M218.3 8.486C230.6-2.829 249.4-2.829 261.7 8.486L469.7 200.5C476.4 206.7 480 215.2 480 224H336C316.9 224 299.7 232.4 288 245.7V208C288 199.2 280.8 192 272 192H208C199.2 192 192 199.2 192 208V272C192 280.8 199.2 288 208 288H271.1V416H112C85.49 416 64 394.5 64 368V256H32C18.83 256 6.996 247.9 2.198 235.7C-2.6 223.4 .6145 209.4 10.3 200.5L218.3 8.486zM336 256H560C577.7 256 592 270.3 592 288V448H624C632.8 448 640 455.2 640 464C640 490.5 618.5 512 592 512H303.1C277.5 512 255.1 490.5 255.1 464C255.1 455.2 263.2 448 271.1 448H303.1V288C303.1 270.3 318.3 256 336 256zM352 304V448H544V304H352z"></path>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" id="icon-link-slash">
<path d="M485.1 354.9l113.5-113.5c55.21-55.21 55.21-144.7 0-199.9C570.1 13.8 534.8 0 498.6 0s-72.36 13.8-99.96 41.41l-43.36 43.36c15.11 8.012 29.47 17.58 41.91 30.02c3.146 3.146 5.898 6.518 8.742 9.838l37.96-37.96C458.5 72.05 477.1 64 498.6 64s40.1 8.047 54.71 22.66c14.61 14.61 22.66 34.04 22.66 54.71s-8.049 40.1-22.66 54.71l-119 119l-30.09-23.59c21.49-51.28 12.12-112.4-29.63-154.1C346.1 109.8 310.8 96 274.6 96c-29.6 0-58.93 9.752-83.83 28.23L38.81 5.109C34.41 1.672 29.19 0 24.03 0c-7.125 0-14.19 3.156-18.91 9.187c-8.188 10.44-6.375 25.53 4.062 33.7l591.1 463.1c10.5 8.203 25.56 6.328 33.69-4.078c8.188-10.44 6.375-25.53-4.062-33.7L485.1 354.9zM350.8 249.6L244.3 166.2C253.8 162.2 264 160 274.6 160c20.67 0 40.1 8.049 54.71 22.66c14.62 14.61 22.66 34.04 22.66 54.71C352 241.5 351.4 245.6 350.8 249.6zM234 387.4l-37.96 37.96C181.5 439.1 162 448 141.4 448c-20.67 0-40.1-8.047-54.71-22.66c-14.61-14.61-22.66-34.04-22.66-54.71s8.049-40.1 22.66-54.71l84.83-84.83L120.7 191.3L41.41 270.7c-55.21 55.21-55.21 144.7 0 199.9C69.01 498.2 105.2 512 141.4 512c36.18 0 72.36-13.8 99.96-41.41l43.36-43.36c-15.11-8.012-29.47-17.58-41.91-30.02C239.6 394.1 236.9 390.7 234 387.4zM265.4 374.6C293 402.2 329.2 416 365.4 416c11.98 0 23.84-2.082 35.51-5.111L224.6 272.7C223.9 309.5 237.3 346.5 265.4 374.6z"></path>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" id="icon-eye-slash">
<path d="M320 400c-75.85 0-137.25-58.71-142.9-133.11L72.2 185.82c-13.79 17.3-26.48 35.59-36.72 55.59a32.35 32.35 0 0 0 0 29.19C89.71 376.41 197.07 448 320 448c26.91 0 52.87-4 77.89-10.46L346 397.39a144.13 144.13 0 0 1-26 2.61zm313.82 58.1l-110.55-85.44a331.25 331.25 0 0 0 81.25-102.07 32.35 32.35 0 0 0 0-29.19C550.29 135.59 442.93 64 320 64a308.15 308.15 0 0 0-147.32 37.7L45.46 3.37A16 16 0 0 0 23 6.18L3.37 31.45A16 16 0 0 0 6.18 53.9l588.36 454.73a16 16 0 0 0 22.46-2.81l19.64-25.27a16 16 0 0 0-2.82-22.45zm-183.72-142l-39.3-30.38A94.75 94.75 0 0 0 416 256a94.76 94.76 0 0 0-121.31-92.21A47.65 47.65 0 0 1 304 192a46.64 46.64 0 0 1-1.54 10l-73.61-56.89A142.31 142.31 0 0 1 320 112a143.92 143.92 0 0 1 144 144c0 21.63-5.29 41.79-13.9 60.11z"></path>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" id="icon-feather">
<path d="M483.4 244.2L351.9 287.1h97.74c-9.874 10.62 3.75-3.125-46.24 46.87l-147.6 49.12h98.24c-74.99 73.12-194.6 70.62-246.8 54.1l-66.14 65.99c-9.374 9.374-24.6 9.374-33.98 0s-9.374-24.6 0-33.98l259.5-259.2c6.249-6.25 6.249-16.37 0-22.62c-6.249-6.249-16.37-6.249-22.62 0l-178.4 178.2C58.78 306.1 68.61 216.7 129.1 156.3l85.74-85.68c90.62-90.62 189.8-88.27 252.3-25.78C517.8 95.34 528.9 169.7 483.4 244.2z"></path>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" id="icon-fast-forward">
<path d="M52.51 440.6l171.5-142.9V214.3L52.51 71.41C31.88 54.28 0 68.66 0 96.03v319.9C0 443.3 31.88 457.7 52.51 440.6zM308.5 440.6l192-159.1c15.25-12.87 15.25-36.37 0-49.24l-192-159.1c-20.63-17.12-52.51-2.749-52.51 24.62v319.9C256 443.3 287.9 457.7 308.5 440.6z"></path>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" id="icon-graph-rising">
<path d="M472 432h-48a24 24 0 01-24-24V104a24 24 0 0124-24h48a24 24 0 0124 24v304a24 24 0 01-24 24zM344 432h-48a24 24 0 01-24-24V184a24 24 0 0124-24h48a24 24 0 0124 24v224a24 24 0 01-24 24zM216 432h-48a24 24 0 01-24-24V248a24 24 0 0124-24h48a24 24 0 0124 24v160a24 24 0 01-24 24zM88 432H40a24 24 0 01-24-24v-96a24 24 0 0124-24h48a24 24 0 0124 24v96a24 24 0 01-24 24z"></path>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512" id="icon-rating">
<path d="M259.3 17.8L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l130.7 68.7c23.2 12.2 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0z"></path>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" id="icon-rewind">
<path d="M459.5 71.41l-171.5 142.9v83.45l171.5 142.9C480.1 457.7 512 443.3 512 415.1V96.03C512 68.66 480.1 54.28 459.5 71.41zM203.5 71.41L11.44 231.4c-15.25 12.87-15.25 36.37 0 49.24l192 159.1c20.63 17.12 52.51 2.749 52.51-24.62v-319.9C255.1 68.66 224.1 54.28 203.5 71.41z"></path>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-like-dislike">
<path d="M22.5,10H15.75C15.13,10 14.6,10.38 14.37,10.91L12.11,16.2C12.04,16.37 12,16.56 12,16.75V18A1,1 0 0,0 13,19H18.18L17.5,22.18V22.42C17.5,22.73 17.63,23 17.83,23.22L18.62,24L23.56,19.06C23.83,18.79 24,18.41 24,18V11.5A1.5,1.5 0 0,0 22.5,10M12,6A1,1 0 0,0 11,5H5.82L6.5,1.82V1.59C6.5,1.28 6.37,1 6.17,0.79L5.38,0L0.44,4.94C0.17,5.21 0,5.59 0,6V12.5A1.5,1.5 0 0,0 1.5,14H8.25C8.87,14 9.4,13.62 9.63,13.09L11.89,7.8C11.96,7.63 12,7.44 12,7.25V6Z"></path>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" id="icon-person-slash">
<path d="M95.1 477.3c0 19.14 15.52 34.67 34.66 34.67h378.7c5.625 0 10.73-1.65 15.42-4.029L264.9 304.3C171.3 306.7 95.1 383.1 95.1 477.3zM630.8 469.1l-277.1-217.9c54.69-14.56 95.18-63.95 95.18-123.2C447.1 57.31 390.7 0 319.1 0C250.2 0 193.7 55.93 192.3 125.4l-153.4-120.3C34.41 1.672 29.19 0 24.03 0C16.91 0 9.845 3.156 5.127 9.187c-8.187 10.44-6.375 25.53 4.062 33.7L601.2 506.9c10.5 8.203 25.56 6.328 33.69-4.078C643.1 492.4 641.2 477.3 630.8 469.1z"></path>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-image-slash">
<path d="M21 17.2L6.8 3H19C20.1 3 21 3.9 21 5V17.2M20.7 22L19.7 21H5C3.9 21 3 20.1 3 19V4.3L2 3.3L3.3 2L22 20.7L20.7 22M16.8 18L12.9 14.1L11 16.5L8.5 13.5L5 18H16.8Z"></path>
</symbol>
@ -60,14 +37,10 @@
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-responsive">
<path d="M4,6V16H9V12A2,2 0 0,1 11,10H16A2,2 0 0,1 18,12V16H20V6H4M0,20V18H4A2,2 0 0,1 2,16V6A2,2 0 0,1 4,4H20A2,2 0 0,1 22,6V16A2,2 0 0,1 20,18H24V20H18V20C18,21.11 17.1,22 16,22H11A2,2 0 0,1 9,20H9L0,20M11.5,20A0.5,0.5 0 0,0 11,20.5A0.5,0.5 0 0,0 11.5,21A0.5,0.5 0 0,0 12,20.5A0.5,0.5 0 0,0 11.5,20M15.5,20A0.5,0.5 0 0,0 15,20.5A0.5,0.5 0 0,0 15.5,21A0.5,0.5 0 0,0 16,20.5A0.5,0.5 0 0,0 15.5,20M13,20V21H14V20H13M11,12V19H16V12H11Z"></path>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-legal">
<path d="M12,3C10.73,3 9.6,3.8 9.18,5H3V7H4.95L2,14C1.53,16 3,17 5.5,17C8,17 9.56,16 9,14L6.05,7H9.17C9.5,7.85 10.15,8.5 11,8.83V20H2V22H22V20H13V8.82C13.85,8.5 14.5,7.85 14.82,7H17.95L15,14C14.53,16 16,17 18.5,17C21,17 22.56,16 22,14L19.05,7H21V5H14.83C14.4,3.8 13.27,3 12,3M12,5A1,1 0 0,1 13,6A1,1 0 0,1 12,7A1,1 0 0,1 11,6A1,1 0 0,1 12,5M5.5,10.25L7,14H4L5.5,10.25M18.5,10.25L20,14H17L18.5,10.25Z"></path>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-search">
<path d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"></path>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-code-block">
<path d="M5,3H7V5H5V10A2,2 0 0,1 3,12A2,2 0 0,1 5,14V19H7V21H5C3.93,20.73 3,20.1 3,19V15A2,2 0 0,0 1,13H0V11H1A2,2 0 0,0 3,9V5A2,2 0 0,1 5,3M19,3A2,2 0 0,1 21,5V9A2,2 0 0,0 23,11H24V13H23A2,2 0 0,0 21,15V19A2,2 0 0,1 19,21H17V19H19V14A2,2 0 0,1 21,12A2,2 0 0,1 19,10V5H17V3H19M12,15A1,1 0 0,1 13,16A1,1 0 0,1 12,17A1,1 0 0,1 11,16A1,1 0 0,1 12,15M8,15A1,1 0 0,1 9,16A1,1 0 0,1 8,17A1,1 0 0,1 7,16A1,1 0 0,1 8,15M16,15A1,1 0 0,1 17,16A1,1 0 0,1 16,17A1,1 0 0,1 15,16A1,1 0 0,1 16,15Z"></path>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-external-link">
<path d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"></path>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="icon-ads-slash">
<path d="M12.2 9L10.2 7H13C14.1 7 15 7.9 15 9V11.8L13 9.8V9H12.2M23 9V7H19C17.9 7 17 7.9 17 9V11C17 12.1 17.9 13 19 13H21V15H18.2L20.2 17H21C22.1 17 23 16.1 23 15V13C23 11.9 22.1 11 21 11H19V9H23M22.1 21.5L20.8 22.8L14.4 16.4C14.1 16.7 13.6 17 13 17H9V10.9L7 8.9V17H5V13H3V17H1V9C1 7.9 1.9 7 3 7H5.1L1.1 3L2.4 1.7L22.1 21.5M5 9H3V11H5V9M13 14.9L11 12.9V15H13V14.9Z"></path>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

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;

View 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']
// }}}
// }

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

View file

@ -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>

View file

@ -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&nbsp;
inspired by projects like{' '}
<a href='https://codeberg.org/teddit/teddit' className='link'>
teddit
</a>
,&nbsp;
,{' '}
<a href='https://github.com/zedeus/nitter' className='link'>
nitter
</a>
, and&nbsp;
, and{' '}
<a
href='https://github.com/digitalblossom/alternative-frontends'
className='link'

77
src/pages/find/index.tsx Normal file
View 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;

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

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

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

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

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

View 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)
}
}

View file

@ -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 {

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

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

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

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

View file

@ -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;
}
};