Compare commits
9 commits
d85c59b21e
...
a33ea9af5f
Author | SHA1 | Date | |
---|---|---|---|
a33ea9af5f | |||
b52fae835e | |||
9dbbc9b431 | |||
0d4b1656d6 | |||
b58600eb50 | |||
d4e12c381d | |||
abadf0aae5 | |||
fee2e98a90 | |||
96c2bbfd12 |
13 changed files with 367 additions and 15 deletions
|
@ -17,5 +17,16 @@ GITHUB_CLIENT_SECRET=
|
|||
# Discord webhook to log audit events to
|
||||
AUDIT_WEBHOOK=
|
||||
|
||||
# MeiliSearch
|
||||
MEILISEARCH_URL=
|
||||
NEXT_PUBLIC_MEILISEARCH_URL=
|
||||
|
||||
# optional - you should set this in production
|
||||
# this key should have write permissions for the search database
|
||||
# MEILISEARCH_KEY=
|
||||
# optional - you should set this in production
|
||||
# this key should only have read permissions for the search database
|
||||
# NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY=
|
||||
|
||||
# Path to the database file (relative to the prisma directory)
|
||||
DATABASE_URL="file:./database.db"
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
DetailedHTMLProps,
|
||||
} from 'react';
|
||||
import Link from 'next/link';
|
||||
import { UrlObject } from 'url';
|
||||
|
||||
interface CustomProps {
|
||||
noDefaultColous?: boolean;
|
||||
|
@ -38,11 +39,18 @@ export default function Button({
|
|||
return <button className={classes.join(' ')} {...props} />;
|
||||
}
|
||||
|
||||
type LinkButtonProps = AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||
type LinkButtonProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> & {
|
||||
href: string | UrlObject;
|
||||
};
|
||||
|
||||
export function LinkButton({ className, children, ...props }: LinkButtonProps) {
|
||||
export function LinkButton({
|
||||
className,
|
||||
children,
|
||||
href,
|
||||
...props
|
||||
}: LinkButtonProps) {
|
||||
return (
|
||||
<Link href={props.href || ''}>
|
||||
<Link href={href}>
|
||||
<a
|
||||
className={'text-blue-200 hover:underline ' + (className || '')}
|
||||
{...props}
|
||||
|
|
72
components/HighlightComic.tsx
Normal file
72
components/HighlightComic.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
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}
|
||||
alt={props.page.title}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{props.highlightedBubbles &&
|
||||
props.highlightedBubbles.map((bubble) => {
|
||||
return (
|
||||
<div
|
||||
key={bubble}
|
||||
className='box-border'
|
||||
aria-hidden
|
||||
style={{
|
||||
border: `1.5px dashed ${randomColour(
|
||||
props.page.id,
|
||||
0.9,
|
||||
0.49,
|
||||
0.5
|
||||
)}`,
|
||||
backgroundColor: randomColour(
|
||||
props.page.id,
|
||||
0.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>
|
||||
);
|
||||
}
|
|
@ -8,12 +8,12 @@ export default function NavBar() {
|
|||
|
||||
return (
|
||||
<nav>
|
||||
<ul className='flex flex-row h-12 bg-gray-900 justify-start items-center p-4 gap-1 shadow mb-4'>
|
||||
<li>
|
||||
<ul className='flex flex-row h-12 bg-gray-900 justify-start items-center p-4 gap-1 shadow mb-4 dots-between'>
|
||||
<li className='no-dot'>
|
||||
<LinkButton href='/'>Comicbox</LinkButton>
|
||||
</li>
|
||||
|
||||
<li className='flex-1' aria-hidden />
|
||||
<li className='flex-1 no-dot' aria-hidden />
|
||||
|
||||
<li>
|
||||
{status === 'loading' && <>...</>}
|
||||
|
@ -28,7 +28,7 @@ export default function NavBar() {
|
|||
)}
|
||||
{status === 'authenticated' && (
|
||||
<>
|
||||
<span>{data?.user?.name}</span>
|
||||
<span>{data?.user?.name}</span>{' '}
|
||||
<button onClick={() => signOut()}>(Log out)</button>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -8,7 +8,8 @@
|
|||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
"format:check": "prettier --check .",
|
||||
"update-indexes": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' src/export-data.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bmunozg/react-image-area": "^1.0.2",
|
||||
|
@ -19,6 +20,7 @@
|
|||
"@prisma/client": "^4.1.0",
|
||||
"diff": "^5.1.0",
|
||||
"discord.js": "^14.0.3",
|
||||
"meilisearch": "^0.27.0",
|
||||
"next": "12.2.2",
|
||||
"next-auth": "^4.10.1",
|
||||
"react": "18.2.0",
|
||||
|
|
179
pages/comic/[comic]/search.tsx
Normal file
179
pages/comic/[comic]/search.tsx
Normal file
|
@ -0,0 +1,179 @@
|
|||
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, props.comic.id]);
|
||||
|
||||
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={result.url}
|
||||
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,13 @@ 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
|
||||
|
|
|
@ -17,6 +17,7 @@ specifiers:
|
|||
discord.js: ^14.0.3
|
||||
eslint: 8.20.0
|
||||
eslint-config-next: 12.2.2
|
||||
meilisearch: ^0.27.0
|
||||
next: 12.2.2
|
||||
next-auth: ^4.10.1
|
||||
postcss: ^8.4.14
|
||||
|
@ -40,6 +41,7 @@ dependencies:
|
|||
'@prisma/client': 4.1.0_prisma@4.1.0
|
||||
diff: 5.1.0
|
||||
discord.js: 14.0.3
|
||||
meilisearch: 0.27.0
|
||||
next: 12.2.2_biqbaboplfbrettd7655fr4n2y
|
||||
next-auth: 4.10.1_biqbaboplfbrettd7655fr4n2y
|
||||
react: 18.2.0
|
||||
|
@ -1110,6 +1112,14 @@ packages:
|
|||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||
dev: true
|
||||
|
||||
/cross-fetch/3.1.5:
|
||||
resolution: {integrity: sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==}
|
||||
dependencies:
|
||||
node-fetch: 2.6.7
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
dev: false
|
||||
|
||||
/cross-spawn/7.0.3:
|
||||
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
|
||||
engines: {node: '>= 8'}
|
||||
|
@ -2125,6 +2135,14 @@ packages:
|
|||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||
dev: true
|
||||
|
||||
/meilisearch/0.27.0:
|
||||
resolution: {integrity: sha512-kZOZFIuSO7c6xRf+Y2/9/h6A9pl0sCl/G44X4KuaSwxGbruOZPhmxbeVEgLHBv4pUFvQ56rNVTA/2d/5GCU1YA==}
|
||||
dependencies:
|
||||
cross-fetch: 3.1.5
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
dev: false
|
||||
|
||||
/memoize-one/5.2.1:
|
||||
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
||||
dev: false
|
||||
|
|
|
@ -6,10 +6,6 @@ declare global {
|
|||
var __prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
global.__prisma ||
|
||||
new PrismaClient({
|
||||
log: ['query'],
|
||||
});
|
||||
export const prisma = global.__prisma || new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') global.__prisma = prisma;
|
||||
|
|
49
src/export-data.ts
Normal file
49
src/export-data.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* This script exports bubble data to external databases.
|
||||
* Most notably, meilisearch, which is used to power the
|
||||
* search feature, although this script will later also
|
||||
* be used to generate the database dumps that will be
|
||||
* published to GitHub.
|
||||
*/
|
||||
|
||||
import MeiliSearch from 'meilisearch';
|
||||
import { prisma } from './db';
|
||||
|
||||
(async function main() {
|
||||
if (!process.env.MEILISEARCH_URL)
|
||||
throw new Error('meilisearch url must be set');
|
||||
|
||||
const meilisearch = new MeiliSearch({
|
||||
host: process.env.MEILISEARCH_URL,
|
||||
});
|
||||
|
||||
const pages = await prisma.comicPage.findMany({
|
||||
include: {
|
||||
bubbles: true,
|
||||
comic: true,
|
||||
},
|
||||
});
|
||||
|
||||
const index = meilisearch.index('comic_pages');
|
||||
|
||||
await index.addDocuments(
|
||||
pages.map((page) => {
|
||||
return {
|
||||
...page,
|
||||
id: `${page.comic.id}-${page.id}`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
console.log('submitted', pages.length, 'pages to be indexed');
|
||||
|
||||
// Limit what can be searched
|
||||
await index.updateSearchableAttributes([
|
||||
'bubbles.content',
|
||||
'title',
|
||||
'comic.title',
|
||||
]);
|
||||
|
||||
// Allow filtering by comic
|
||||
await index.updateFilterableAttributes(['comic.id']);
|
||||
})();
|
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,7 +9,7 @@
|
|||
}
|
||||
|
||||
@layer components {
|
||||
.dots-between > li:not(:last-child)::after {
|
||||
.dots-between > li:not(:last-child):not(.no-dot)::after {
|
||||
content: '⦁';
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
|
|
@ -9,6 +9,9 @@ module.exports = {
|
|||
maxWidth: {
|
||||
'1/2': '50%',
|
||||
},
|
||||
minWidth: {
|
||||
xl: '36rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
|
Loading…
Reference in a new issue