Compare commits

...

9 commits

13 changed files with 367 additions and 15 deletions

View file

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

View file

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

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

View file

@ -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>
</>
)}

View file

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

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

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

View file

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

View file

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

View file

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