feat: comic searching
This commit is contained in:
parent
d4e12c381d
commit
b58600eb50
6 changed files with 259 additions and 1 deletions
44
components/HighlightComic.tsx
Normal file
44
components/HighlightComic.tsx
Normal 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>
|
||||
}
|
63
pages/comic/[comic]/highlight/[page].tsx
Normal file
63
pages/comic/[comic]/highlight/[page].tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
136
pages/comic/[comic]/search.tsx
Normal file
136
pages/comic/[comic]/search.tsx
Normal 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);
|
||||
}
|
|
@ -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
6
src/search.ts
Normal 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,
|
||||
});
|
|
@ -9,6 +9,9 @@ module.exports = {
|
|||
maxWidth: {
|
||||
'1/2': '50%',
|
||||
},
|
||||
minWidth: {
|
||||
'xl': '36rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
|
Loading…
Reference in a new issue