feat: comic searching

This commit is contained in:
Ashhhleyyy 2022-08-01 17:18:55 +01:00
parent d4e12c381d
commit b58600eb50
Signed by: ash
GPG key ID: 83B789081A0878FB
6 changed files with 259 additions and 1 deletions

View file

@ -0,0 +1,44 @@
import { Comic, ComicBubble, ComicPage } from "@prisma/client";
import Image from "next/image"
import { percentify, randomColour } from "../src/utils";
interface Props {
page: ComicPage & {
bubbles: ComicBubble[];
comic: Comic;
};
highlightedBubbles: number[] | null;
}
export default function HighlightComic(props: Props) {
return <div className='relative box-border inline-block select-none'>
<div className='box-border max-w-full'>
<Image src={props.page.imageUrl} width={props.page.width} height={props.page.height} style={{
position: 'absolute',
top: 0,
left: 0,
}} />
</div>
{props.highlightedBubbles && props.highlightedBubbles.map(bubble => {
return <div className='box-border' style={{
border: `1.5px dashed ${randomColour(
props.page.id,
.9,
0.49,
0.5
)}`,
backgroundColor: randomColour(
props.page.id,
.9,
undefined,
0.5
),
position: 'absolute',
left: `${percentify(props.page.width, props.page.bubbles[bubble].areaX)}%`,
top: `${percentify(props.page.height, props.page.bubbles[bubble].areaY)}%`,
width: `${percentify(props.page.width, props.page.bubbles[bubble].areaWidth)}%`,
height: `${percentify(props.page.height, props.page.bubbles[bubble].areaHeight)}%`,
}} />
})}
</div>
}

View file

@ -0,0 +1,63 @@
import { Comic, ComicBubble, ComicPage } from "@prisma/client";
import { GetServerSideProps } from "next";
import Head from "next/head";
import { ParsedUrlQuery } from "querystring";
import HighlightComic from "../../../../components/HighlightComic";
import { prisma } from "../../../../src/db";
interface Params extends ParsedUrlQuery {
comic: string;
page: string;
}
interface Props {
page: ComicPage & {
bubbles: ComicBubble[];
comic: Comic;
};
highlightedBubbles: number[] | null;
}
export default function Search(props: Props) {
return <main className='p-4'>
<Head>
<title>{props.page.title} - {props.page.comic.title}</title>
</Head>
<HighlightComic page={props.page} highlightedBubbles={props.highlightedBubbles} />
</main>
}
export const getServerSideProps: GetServerSideProps<Props, Params> = async ({
params,
query,
}) => {
const { comic: comicId, page: id } = params!;
const pageId = parseInt(id);
if (isNaN(pageId)) return { notFound: true };
const page = await prisma?.comicPage.findFirst({
where: {
comic: {
slug: comicId,
},
id: pageId,
},
include: {
bubbles: true,
comic: true,
},
});
if (!page) return { notFound: true };
const highlightedBubbles = (Array.isArray(query.highlightedBubbles) ? query.highlightedBubbles : (query.highlightedBubbles ? [query.highlightedBubbles] : undefined))
?.map((bubble) => parseInt(bubble))
?.filter((bubble) => !isNaN(bubble));
return {
props: {
page,
highlightedBubbles: highlightedBubbles ?? null,
}
}
}

View file

@ -0,0 +1,136 @@
import { Comic, ComicBubble, ComicPage } from "@prisma/client";
import { Hit, SearchResponse } from "meilisearch";
import { GetServerSideProps } from "next";
import Head from "next/head";
import { useRouter } from "next/router";
import { ParsedUrlQuery } from "querystring";
import { useEffect, useState } from "react";
import { LinkButton } from "../../../components/Button";
import HighlightComic from "../../../components/HighlightComic";
import TextInput from "../../../components/TextInput";
import { prisma } from "../../../src/db";
import { searchClient } from "../../../src/search";
interface Params extends ParsedUrlQuery {
comic: string;
}
interface Props {
initialSearchQuery: string;
comic: Comic;
}
type ComicPageResult = { id: string; } & Omit<ComicPage, 'id'> & {
comic: Comic;
bubbles: ComicBubble[];
}
export default function Search(props: Props) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState(props.initialSearchQuery);
const [results, setResults] = useState<SearchResponse<ComicPageResult>>();
const [selected, setSelected] = useState(-1);
useEffect(() => {
setSearchQuery((Array.isArray(router.query.search) ? router.query.search[0] : router.query.search) || '');
}, [router.query]);
useEffect(() => {
searchClient.index('comic_pages').search<ComicPageResult>(searchQuery, {
attributesToHighlight: ['*'],
filter: [
`comic.id=${props.comic.id}`,
],
}).then(results => {
setResults(results);
});
}, [searchQuery]);
return <main className='p-4'>
<Head>
<title>Search {props.comic.title}</title>
</Head>
<div className='w-full flex flex-col items-center justify-center'>
<TextInput multiline={false} value={searchQuery} onChange={(e) => {
router.replace({
pathname: `/comic/${props.comic.slug}/search`,
query: {
search: e.target.value
},
}, undefined, {
shallow: true,
});
setSearchQuery(e.target.value);
setSelected(-1);
}} placeholder='Type to search...' className='w-full max-w-xl' />
</div>
{results && <div className='flex flex-row w-full gap-4 justify-center pt-4'>
<section className={'max-w-1/2 ' + (selected === -1 && 'min-w-xl')}>
<p>
<i>~{results.estimatedTotalHits} total results</i>
</p>
<ul>
{results.hits.map((result, i) => {
return <li key={result.id}>
<div>
<h2>
<LinkButton href={{
pathname: `/comic/${props.comic.slug}/highlight/${result.id.split('-')[1]}`,
query: {
highlightedBubbles: findHighlighedBubbles(result),
},
}} target='_blank' onClick={(e) => {
e.preventDefault();
setSelected(i === selected ? -1 : i);
}}>
{result.title}
</LinkButton>
</h2>
</div>
</li>
})}
</ul>
</section>
{selected !== -1 && results.hits[selected] && <section className='max-w-1/2'>
<h2>{results.hits[selected].title}</h2>
<HighlightComic page={{
...results.hits[selected],
id: parseInt(results.hits[selected].id.split('-')[1]),
}} highlightedBubbles={findHighlighedBubbles(results.hits[selected])} />
</section>}
</div>}
</main>
}
export const getServerSideProps: GetServerSideProps<Props, Params> = async ({ params, query }) => {
const { comic: comicId } = params!;
const comic = await prisma.comic.findFirst({
where: {
slug: comicId,
},
});
if (!comic) return { notFound: true };
const search = (Array.isArray(query.search) ? query.search[0] : query.search) || '';
return {
props: {
initialSearchQuery: search,
comic,
},
}
}
function findHighlighedBubbles(result: Hit<ComicPageResult>) {
return result.bubbles.filter((bubble, i) => {
if (!result._formatted?.bubbles) throw new Error();
const fmt = result._formatted?.bubbles[i].content;
return fmt !== bubble.content;
}).map(bubble => bubble.idx);
}

View file

@ -26,8 +26,9 @@ const Home = (props: Props) => {
<thead>
<tr className='text-left'>
<th>Comic</th>
<th>Search</th>
<th>Completion</th>
<th>Links</th>
<th>Link</th>
</tr>
</thead>
<tbody>
@ -39,6 +40,11 @@ const Home = (props: Props) => {
{comic.title}
</LinkButton>
</td>
<td>
<LinkButton href={`/comic/${comic.slug}/search`}>
Search
</LinkButton>
</td>
<td className='w-96 flex flex-row items-center'>
<CompletionBar
inline

6
src/search.ts Normal file
View file

@ -0,0 +1,6 @@
import MeiliSearch from "meilisearch";
export const searchClient = new MeiliSearch({
host: process.env.NEXT_PUBLIC_MEILISEARCH_URL!,
apiKey: process.env.NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY,
});

View file

@ -9,6 +9,9 @@ module.exports = {
maxWidth: {
'1/2': '50%',
},
minWidth: {
'xl': '36rem',
},
},
},
plugins: [],