feat: initial app implementation, including rain comic data

This commit is contained in:
Ashhhleyyy 2022-07-25 18:22:59 +01:00
parent 31db2b59cd
commit a56c934854
Signed by: ash
GPG key ID: 83B789081A0878FB
38 changed files with 25150 additions and 256 deletions

6
.env.template Normal file
View file

@ -0,0 +1,6 @@
# Example configuration
# Copy this to .env and adjust as required
KEYCLOAK_ID=
KEYCLOAK_SECRET=
KEYCLOAK_ISSUER=
DATABASE_URL="file:./database.db"

View file

@ -1,3 +1,3 @@
{
"extends": "next/core-web-vitals"
"extends": "next/core-web-vitals"
}

3
.gitignore vendored
View file

@ -33,3 +33,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
# prisma
prisma/dev.db*

32
.prettierignore Normal file
View file

@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# lockfile
pnpm-lock.yaml
# CI
.pnpm-store
.drone.yml
node_modules
dist
dist-ssr
*.local
.next/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
.prettierrc Normal file
View file

@ -0,0 +1,8 @@
{
"jsxSingleQuote": true,
"semi": true,
"tabWidth": 4,
"useTabs": false,
"trailingComma": "es5",
"singleQuote": true
}

View file

@ -22,8 +22,8 @@ The `pages/api` directory is mapped to `/api/*`. Files in this directory are tre
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!

16
components/Button.tsx Normal file
View file

@ -0,0 +1,16 @@
import { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps, RefAttributes } from "react";
import Link, { LinkProps } from 'next/link';
export default function Button({ className, ...props }: DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>) {
return <button className={'bg-slate-600 text-slate-50 p-1 rounded shadow shadow-slate-900 hover:bg-slate-500 transition-colors ' + (className || '')} {...props} />
}
type LinkButtonProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps> & LinkProps & {
children?: React.ReactNode;
} & RefAttributes<HTMLAnchorElement>;
export function LinkButton({ className, children, ...props }: LinkButtonProps) {
return <Link {...props}>
<a className={'text-blue-200 hover:underline ' + (className || '')}>{children}</a>
</Link>
}

16
components/NavBar.tsx Normal file
View file

@ -0,0 +1,16 @@
import { signIn, signOut, useSession } from "next-auth/react";
export default function NavBar() {
const { data, status } = useSession();
return (
<nav className='flex flex-row h-12 bg-gray-900 justify-end items-center p-4 gap-1 shadow mb-4'>
{status === 'loading' && <>...</>}
{status === 'unauthenticated' && <button onClick={() => signIn('keycloak')}>Log in</button>}
{status === 'authenticated' && <>
<span>{data?.user?.name}</span>
<button onClick={() => signOut()}>(Log out)</button>
</>}
</nav>
)
}

12
components/TextInput.tsx Normal file
View file

@ -0,0 +1,12 @@
import { DetailedHTMLProps, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
type Props = ({ multiline: true } & DetailedHTMLProps<TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>)
| ({ multiline: false | undefined } & DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>);
export default function TextInput({ multiline, className, ...props }: Props) {
if (multiline) {
return <textarea className={'bg-slate-600 p-1 overflow-auto rounded ' + className} {...props as any} />
} else {
return <input className={'bg-slate-600 p-1 overflow-auto rounded ' + className} {...props as any} />
}
}

View file

@ -1,7 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
}
reactStrictMode: true,
swcMinify: true,
images: {
domains: [
'img.comicfury.com'
],
},
};
module.exports = nextConfig
module.exports = nextConfig;

View file

@ -1,24 +1,39 @@
{
"name": "comicbox",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "12.2.2",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@types/node": "18.0.6",
"@types/react": "18.0.15",
"@types/react-dom": "18.0.6",
"eslint": "8.20.0",
"eslint-config-next": "12.2.2",
"typescript": "4.7.4"
}
"name": "comicbox",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@bmunozg/react-image-area": "^1.0.2",
"@prisma/client": "^4.1.0",
"next": "12.2.2",
"next-auth": "^4.10.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-select": "^5.4.0",
"tesseract.js": "^2.1.5",
"zod": "^3.17.10"
},
"devDependencies": {
"@types/node": "18.0.6",
"@types/react": "18.0.15",
"@types/react-dom": "18.0.6",
"autoprefixer": "^10.4.7",
"eslint": "8.20.0",
"eslint-config-next": "12.2.2",
"postcss": "^8.4.14",
"prettier": "^2.7.1",
"prisma": "^4.1.0",
"tailwindcss": "^3.1.6",
"ts-node": "^10.9.1",
"typescript": "4.7.4"
},
"prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}
}

View file

@ -1,8 +1,15 @@
import '../styles/globals.css'
import type { AppProps } from 'next/app'
import '../styles/globals.css';
import type { AppProps } from 'next/app';
import { SessionProvider } from 'next-auth/react';
import NavBar from '../components/NavBar';
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
return <>
<SessionProvider>
<NavBar />
<Component {...pageProps} />
</SessionProvider>
</>;
}
export default MyApp
export default MyApp;

View file

@ -0,0 +1,15 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import Keycloak from "next-auth/providers/keycloak";
import process from "process";
export const authOptions: NextAuthOptions = {
providers: [
Keycloak({
clientId: process.env.KEYCLOAK_ID!,
clientSecret: process.env.KEYCLOAK_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER!,
}),
],
};
export default NextAuth(authOptions);

View file

@ -1,13 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'
import type { NextApiRequest, NextApiResponse } from 'next';
type Data = {
name: string
}
name: string;
};
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
req: NextApiRequest,
res: NextApiResponse<Data>
) {
res.status(200).json({ name: 'John Doe' })
res.status(200).json({ name: 'John Doe' });
}

View file

@ -0,0 +1,89 @@
import { ComicBubble, Prisma } from "@prisma/client";
import { NextApiRequest, NextApiResponse } from "next";
import { unstable_getServerSession } from "next-auth";
import { z } from 'zod';
import { prisma } from "../../src/db";
import { authOptions } from "./auth/[...nextauth]";
const PageBubblesSchema = z.object({
bubbles: z.array(z.object({
area: z.object({
x: z.number(),
y: z.number(),
width: z.number(),
height: z.number(),
}),
character: z.number(),
index: z.number(),
text: z.string(),
})),
comicId: z.number(),
pageId: z.number(),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'PUT') return res.status(405).json({ error: 'method not allowed' });
const session = await unstable_getServerSession(req, res, authOptions);
if (!session) return res.status(401).json({ error: 'authentication required' });
const data = PageBubblesSchema.parse(req.body);
const page = await prisma.comicPage.findFirst({
where: {
comicId: data.comicId,
id: data.pageId,
},
});
if (!page) return res.status(404).json({ error: 'not found' });
const existingCharacters: number[] = [];
const ops: Prisma.Prisma__ComicBubbleClient<ComicBubble>[] = [];
for (const bubble of data.bubbles) {
if (!existingCharacters.includes(bubble.character)) {
const char = await prisma.comicCharacter.findFirst({
where: {
id: bubble.character,
}
});
if (!char) {
return res.status(400).json({ message: 'bad request' });
}
}
const op = prisma.comicBubble.upsert({
where: {
comicId_pageId_idx: {
comicId: data.comicId,
pageId: data.pageId,
idx: bubble.index,
},
},
update: {
areaX: bubble.area.x,
areaY: bubble.area.y,
areaWidth: bubble.area.width,
areaHeight: bubble.area.height,
characterId: bubble.character,
content: bubble.text,
},
create: {
comicId: data.comicId,
pageId: data.pageId,
idx: bubble.index,
areaX: bubble.area.x,
areaY: bubble.area.y,
areaWidth: bubble.area.width,
areaHeight: bubble.area.height,
characterId: bubble.character,
content: bubble.text,
}
});
ops.push(op);
}
await prisma.$transaction(ops);
res.status(201).json({ });
}

View file

@ -0,0 +1,90 @@
import { Comic } from "@prisma/client";
import { GetServerSideProps } from "next";
import Head from "next/head";
import { ParsedUrlQuery } from "querystring";
import { LinkButton } from "../../../components/Button";
import { prisma } from "../../../src/db";
interface Params extends ParsedUrlQuery {
comic: string;
}
interface Props {
comic: Comic;
completion: {
completePages: number;
totalPages: number;
}
}
export default function ComicPage({ comic, completion }: Props) {
return <main className='p-4'>
<Head>
<title>{`${comic.title}`}</title>
</Head>
<h1 className='text-4xl pb-2'>
{comic.title}
</h1>
<section>
<p>
So far, a total of {completion.completePages} out of {completion.totalPages} pages have been transcribed!
</p>
</section>
<nav>
<ul>
<li>
<LinkButton href={`/comic/${comic.slug}/transcribe/random`}>
Random page
</LinkButton>
</li>
<li>
<LinkButton href={`/comic/${comic.slug}/transcribe/random?completed=true`}>
Random page (complete)
</LinkButton>
</li>
<li>
<LinkButton href={`/comic/${comic.slug}/transcribe/random?completed=false`}>
Random page (incomplete)
</LinkButton>
</li>
</ul>
</nav>
</main>
}
export const getServerSideProps: GetServerSideProps<Props, Params> = async ({ params }) => {
const { comic: comicId } = params!;
const comic = await prisma.comic.findFirst({
where: {
slug: comicId,
},
});
if (!comic) return { notFound: true };
const pages = await prisma.comicPage.findMany({
where: {
comicId: comic.id,
},
select: {
_count: {
select: {
bubbles: true,
},
},
},
});
return {
props: {
comic,
completion: {
totalPages: pages.length,
completePages: pages.filter(page => page._count.bubbles !== 0).length,
}
}
}
}

View file

@ -0,0 +1,361 @@
import { AreaSelector, IArea, IAreaRendererProps } from "@bmunozg/react-image-area";
import { GetServerSideProps } from "next";
import Head from "next/head";
import Image from 'next/image';
import { ParsedUrlQuery } from "querystring";
import { useCallback, useMemo, useState } from "react";
import Select from 'react-select';
import Button, { LinkButton } from "../../../../components/Button";
import TextInput from "../../../../components/TextInput";
import { prisma } from "../../../../src/db";
import { performOcr } from "../../../../src/ocr";
import { percentify, randomColour, unPercentify } from "../../../../src/utils";
interface Params extends ParsedUrlQuery {
comic: string;
page: string;
}
interface Props {
page: Page;
characters: Character[];
regions: Region[];
}
interface Page {
id: number;
comic: { id: number; slug: string; title: string };
title: string;
imageUrl: string;
width: number;
height: number;
}
interface Character {
id: number;
shortName: string;
}
interface Region {
area: IArea;
text: string;
character: { value: number; label: string } | null;
}
const SelectedArea = (props: IAreaRendererProps) => {
if (!props.isChanging) {
return <div key={props.areaNumber} className='text-slate-900 font-bold bg-slate-50 opacity-90 text-center'>
{props.areaNumber}
</div>
}
}
export default function TranscribePage(props: Props) {
const [regions, setRegions] = useState<Region[]>(props.regions);
const [image, setImage] = useState<HTMLImageElement>();
const [indicateNumbers, setIndicateNumbers] = useState(false);
const [lockAreas, setLockAreas] = useState(false);
const [selectedBubble, setSelectedBubble] = useState(-1);
const extractText = useCallback(() => {
if (image && regions.length > 0) {
const promises = regions.map(region => performOcr(image, region.area));
Promise.all(promises).then(texts => {
setRegions(regions.map((region, i) => {
return {
...region,
text: texts[i],
};
}));
});
}
}, [regions, setRegions, image]);
const characters = useMemo(() => {
return props.characters.map(character => {
return {
value: character.id,
label: character.shortName,
};
})
}, [props.characters]);
async function submitBubbles() {
const res = await fetch('/api/submit-page-bubbles', {
method: 'PUT',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
bubbles: regions.map((region, i) => {
const area = region.area.unit === 'px' ? {
x: region.area.x,
y: region.area.y,
width: region.area.width,
height: region.area.height,
} : {
x: unPercentify(props.page.width, region.area.x),
y: unPercentify(props.page.height, region.area.y),
width: unPercentify(props.page.width, region.area.width),
height: unPercentify(props.page.height, region.area.height),
};
return {
area,
character: region.character?.value ?? -1,
index: i,
text: region.text,
}
}),
comicId: props.page.comic.id,
pageId: props.page.id,
}),
});
if (res.ok) {
console.log('submitted!');
} else {
console.log('failed to submit:', res.status, res.statusText);
}
}
return <main className='p-4'>
<Head>
<title>{`${props.page.title} - ${props.page.comic.title}`}</title>
</Head>
<div className='flex flex-row w-full gap-4'>
<section className='h-full max-w-1/2'>
<header className='mb-4'>
<nav className='flex flex-row w-full'>
<LinkButton className='flex-1 w-full' href={{
pathname: '/comic/[comic]',
query: {
comic: props.page.comic.slug,
}
}}>
&larr; Back to {props.page.comic.title}
</LinkButton>
</nav>
<h1 className='text-4xl pb-2'>{props.page.title}</h1>
<nav className='flex flex-row w-full'>
{props.page.id > 1 && <LinkButton className='flex-1 w-full text-center' href={{
pathname: '/comic/[comic]/transcribe/[page]',
query: {
comic: props.page.comic.slug,
page: props.page.id - 1,
}
}}>
&larr; Previous page
</LinkButton>}
<LinkButton className='flex-1 w-full text-center' href={{
pathname: '/comic/[comic]/transcribe/random',
query: {
comic: props.page.comic.slug,
}
}}>
Random page
</LinkButton>
<LinkButton className='flex-1 w-full text-center' href={{
pathname: '/comic/[comic]/transcribe/[page]',
query: {
comic: props.page.comic.slug,
page: props.page.id + 1,
}
}}>
Next page &rarr;
</LinkButton>
</nav>
</header>
<AreaSelector
areas={regions.map(r => r.area)}
onChange={areas => !lockAreas && setRegions(areas.map((area, i) => {
if (i < regions.length) {
return {
...regions[i],
area,
};
} else {
return {
area,
character: null,
index: i,
text: '',
};
}
}))}
globalAreaStyle={{
border: `1.5px dashed ${randomColour(props.page.comic.id, 0.3, .49, 0.5)}`,
backgroundColor: randomColour(props.page.comic.id, undefined, undefined, 0.5),
}}
customAreaRenderer={indicateNumbers ? SelectedArea : undefined}
unit="percentage"
>
<Image crossOrigin="" src={props.page.imageUrl} alt={props.page.title} width={props.page.width} height={props.page.height} onLoad={e => setImage(e.currentTarget)} />
</AreaSelector>
</section>
<section className='flex-1'>
<div>
<Button onClick={extractText}>Extract text</Button>
<br />
<label htmlFor='indicate-numbers'>
<input id='indicate-numbers' type='checkbox' value={indicateNumbers.toString()} onChange={e => setIndicateNumbers(e.target.checked)} />
Show selection numbers?
</label>
<br />
<label htmlFor='lock-areas'>
<input id='lock-areas' type='checkbox' value={lockAreas.toString()} onChange={e => setLockAreas(e.target.checked)} />
Lock areas
</label>
</div>
<hr className='border-slate-600 my-2' />
<div>
{selectedBubble === -1 && <p>
<em>Click a bubble to edit</em>
</p>}
{selectedBubble !== -1 && <>
<TextInput multiline className='w-full h-24'
value={regions[selectedBubble].text} onChange={(e) => {
const newRegions = regions.slice();
newRegions[selectedBubble].text = e.target.value;
setRegions(newRegions);
}} />
<Select className='text-slate-900' onChange={(v) => {
const newRegions = regions.slice();
newRegions[selectedBubble].character = v;
setRegions(newRegions);
}} options={characters} value={regions[selectedBubble].character} />
<Button className='mt-2 w-full' onClick={() => {
const newRegions = regions.filter((_, i) => i !== selectedBubble);
setRegions(newRegions);
setSelectedBubble(-1);
}}>
Remove
</Button>
</>}
</div>
<hr className='border-slate-600 my-2' />
<div className='grid grid-cols-2 gap-2'>
{regions.map((region, i) => {
return <div
key={i}
onClick={() => setSelectedBubble(selectedBubble === i ? -1 : i)}
className={'border-slate-600 border-2 rounded p-2 ' + (i === selectedBubble ? 'bg-teal-900 border-teal-700' : '')}
>
<h3>Bubble {i + 1}</h3>
<p>
<b>{region.character?.label}</b>: {region.text || '...'}
</p>
</div>;
})}
</div>
<hr className='border-slate-600 my-2' />
<div>
<Button onClick={submitBubbles}>
Save!
</Button>
</div>
</section>
</div>
</main>
}
export const getServerSideProps: GetServerSideProps<Props, Params> = async ({ params }) => {
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,
},
select: {
id: true,
title: true,
imageUrl: true,
width: true,
height: true,
comic: {
select: {
id: true,
slug: true,
title: true,
},
},
},
});
if (!page) return { notFound: true };
const characters = await prisma.comicCharacter.findMany({
where: {
comicId: page.comic.id,
},
select: {
id: true,
shortName: true,
},
});
const bubbles = await prisma.comicBubble.findMany({
where: {
comicId: page.comic.id,
pageId: page.id,
},
select: {
idx: true,
areaX: true,
areaY: true,
areaWidth: true,
areaHeight: true,
content: true,
character: {
select: {
id: true,
shortName: true,
}
}
},
});
return {
props: {
page,
characters,
key: `${page.comic.slug}-${page.id}`,
regions: bubbles.map(bubble => {
return {
character: {
value: bubble.character.id,
label: bubble.character.shortName,
},
area: {
x: percentify(page.width, bubble.areaX),
y: percentify(page.height, bubble.areaY),
width: percentify(page.width, bubble.areaWidth),
height: percentify(page.height, bubble.areaHeight),
unit: '%',
},
index: bubble.idx,
text: bubble.content,
}
})
},
};
}

View file

@ -0,0 +1,51 @@
import { GetServerSideProps } from "next";
import { ParsedUrlQuery } from "querystring";
import { prisma } from "../../../../src/db";
interface Params extends ParsedUrlQuery {
comic: string;
}
export default function RandomPage() {
return <>
Please wait...
</>
}
export const getServerSideProps: GetServerSideProps<{}, Params> = async ({ params, query }) => {
const { comic: comicSlug } = params!;
const pages = await prisma.comicPage.findMany({
where: {
comic: {
slug: comicSlug,
},
},
select: {
id: true,
_count: {
select: {
bubbles: true,
}
}
}
}).then(pages => {
if (query.completed === 'true') {
return pages.filter(page => page._count.bubbles !== 0);
} else if (query.completed === 'false') {
return pages.filter(page => page._count.bubbles === 0);
} else {
return pages;
}
});
console.log(pages.length);
const randomPage = pages[Math.floor(Math.random() * pages.length)];
return {
redirect: {
destination: `/comic/${comicSlug}/transcribe/${randomPage.id}`,
permanent: false,
}
}
}

View file

@ -1,72 +1,11 @@
import type { NextPage } from 'next'
import Head from 'next/head'
import Image from 'next/image'
import styles from '../styles/Home.module.css'
import type { NextPage } from 'next';
const Home: NextPage = () => {
return (
<div className={styles.container}>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" />
</Head>
return (
<main>
<h1 className='text-4xl'>Hello, World!</h1>
</main>
);
};
<main className={styles.main}>
<h1 className={styles.title}>
Welcome to <a href="https://nextjs.org">Next.js!</a>
</h1>
<p className={styles.description}>
Get started by editing{' '}
<code className={styles.code}>pages/index.tsx</code>
</p>
<div className={styles.grid}>
<a href="https://nextjs.org/docs" className={styles.card}>
<h2>Documentation &rarr;</h2>
<p>Find in-depth information about Next.js features and API.</p>
</a>
<a href="https://nextjs.org/learn" className={styles.card}>
<h2>Learn &rarr;</h2>
<p>Learn about Next.js in an interactive course with quizzes!</p>
</a>
<a
href="https://github.com/vercel/next.js/tree/canary/examples"
className={styles.card}
>
<h2>Examples &rarr;</h2>
<p>Discover and deploy boilerplate example Next.js projects.</p>
</a>
<a
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
className={styles.card}
>
<h2>Deploy &rarr;</h2>
<p>
Instantly deploy your Next.js site to a public URL with Vercel.
</p>
</a>
</div>
</main>
<footer className={styles.footer}>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<span className={styles.logo}>
<Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
</span>
</a>
</footer>
</div>
)
}
export default Home
export default Home;

2796
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "Comic" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"url" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "ComicPage" (
"id" INTEGER NOT NULL,
"comicId" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"url" TEXT NOT NULL,
"imageUrl" TEXT NOT NULL,
PRIMARY KEY ("comicId", "id"),
CONSTRAINT "ComicPage_comicId_fkey" FOREIGN KEY ("comicId") REFERENCES "Comic" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Comic_slug_key" ON "Comic"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "ComicPage_comicId_id_key" ON "ComicPage"("comicId", "id");

View file

@ -0,0 +1,27 @@
/*
Warnings:
- Added the required column `height` to the `ComicPage` table without a default value. This is not possible if the table is not empty.
- Added the required column `width` to the `ComicPage` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_ComicPage" (
"id" INTEGER NOT NULL,
"comicId" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"url" TEXT NOT NULL,
"imageUrl" TEXT NOT NULL,
"width" INTEGER NOT NULL,
"height" INTEGER NOT NULL,
PRIMARY KEY ("comicId", "id"),
CONSTRAINT "ComicPage_comicId_fkey" FOREIGN KEY ("comicId") REFERENCES "Comic" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_ComicPage" ("comicId", "id", "imageUrl", "title", "url") SELECT "comicId", "id", "imageUrl", "title", "url" FROM "ComicPage";
DROP TABLE "ComicPage";
ALTER TABLE "new_ComicPage" RENAME TO "ComicPage";
CREATE UNIQUE INDEX "ComicPage_comicId_id_key" ON "ComicPage"("comicId", "id");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "ComicCharacter" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"comicId" INTEGER NOT NULL,
"shortName" TEXT NOT NULL,
"longName" TEXT NOT NULL,
CONSTRAINT "ComicCharacter_comicId_fkey" FOREIGN KEY ("comicId") REFERENCES "Comic" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);

View file

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[longName]` on the table `ComicCharacter` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "ComicCharacter_longName_key" ON "ComicCharacter"("longName");

View file

@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "ComicBubble" (
"comicId" INTEGER NOT NULL,
"pageId" INTEGER NOT NULL,
"idx" INTEGER NOT NULL,
"characterId" INTEGER NOT NULL,
"areaX" INTEGER NOT NULL,
"areaY" INTEGER NOT NULL,
"areaWidth" INTEGER NOT NULL,
"areaHeight" INTEGER NOT NULL,
"content" TEXT NOT NULL,
PRIMARY KEY ("comicId", "pageId", "idx"),
CONSTRAINT "ComicBubble_comicId_pageId_fkey" FOREIGN KEY ("comicId", "pageId") REFERENCES "ComicPage" ("comicId", "id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ComicBubble_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "ComicCharacter" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "ComicBubble_comicId_pageId_idx_key" ON "ComicBubble"("comicId", "pageId", "idx");

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

64
prisma/schema.prisma Normal file
View file

@ -0,0 +1,64 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Comic {
id Int @id @default(autoincrement())
slug String @unique
title String
url String
pages ComicPage[]
characters ComicCharacter[]
}
model ComicPage {
id Int
comic Comic @relation(fields: [comicId], references: [id])
comicId Int
title String
url String
imageUrl String
width Int
height Int
bubbles ComicBubble[]
@@id([comicId, id])
@@unique([comicId, id])
}
model ComicCharacter {
id Int @id @default(autoincrement())
comic Comic @relation(fields: [comicId], references: [id])
comicId Int
shortName String
longName String @unique
bubbles ComicBubble[]
}
model ComicBubble {
comicId Int
page ComicPage @relation(fields: [comicId, pageId], references: [comicId, id])
pageId Int
idx Int
character ComicCharacter @relation(fields: [characterId], references: [id])
characterId Int
areaX Int
areaY Int
areaWidth Int
areaHeight Int
content String
@@id([comicId, pageId, idx])
@@unique([comicId, pageId, idx])
}

View file

@ -0,0 +1,4 @@
Characters page: https://discord.com/channels/188689115842215936/191082545138368512/971511146366505010
Michael Rubina: https://discord.com/channels/188689115842215936/191082545138368512/478019392853245963
Isaac Bailey: https://discord.com/channels/188689115842215936/191082545138368512/751485338777550869

File diff suppressed because it is too large Load diff

105
prisma/seed.ts Normal file
View file

@ -0,0 +1,105 @@
import { prisma } from '../src/db';
import { ComicCharacter, ComicPage, Prisma } from '@prisma/client';
import rain from './seed-data/comics/rain.json';
interface Page {
id: number;
chapterId: number;
name: string;
pageUrl: string;
imageUrl: string;
imageSizeBytes: number;
alt: string;
prevPageId?: number;
nextPageId?: number;
url: string;
width: number;
height: number;
}
async function seedComicRain() {
const comicData = {
slug: 'rain',
url: 'https://rain.thecomicseries.com/',
title: rain.title,
};
const comic = await prisma.comic.upsert({
where: {
slug: 'rain',
},
update: comicData,
create: comicData,
});
{
// Chapters not directly related to the story
const SKIP_CHAPTERS = [
0, // Cover page
30, // SRS Hiatus
37, // Summer 2018 Hiatus
42, // Quarantine Hiatus 2020
48, // Rain delays
49, // Specials
];
const ops: Prisma.Prisma__ComicPageClient<ComicPage>[] = [];
for (const page of rain.pages) {
if (SKIP_CHAPTERS.includes(page.chapterId)) {
continue;
}
const pageData = {
id: page.id,
title: page.name,
imageUrl: page.imageUrl,
url: page.url,
comicId: comic.id,
width: page.width,
height: page.height,
};
const op = prisma.comicPage.upsert({
where: {
comicId_id: {
comicId: comic.id,
id: page.id,
},
},
create: pageData,
update: pageData,
});
ops.push(op);
}
await prisma.$transaction(ops);
}
{
const ops: Prisma.Prisma__ComicCharacterClient<ComicCharacter>[] = [];
for (const char of rain.characters) {
const character = (typeof char === 'string')
? { longName: char, shortName: char }
: char;
const characterData = {
comicId: comic.id,
...character
}
const op = prisma.comicCharacter.upsert({
where: {
longName: character.longName,
},
create: characterData,
update: characterData,
});
ops.push(op);
}
await prisma.$transaction(ops);
}
}
(async function () {
await seedComicRain();
})().catch(e => {
console.error('failed to seed',
e);
process.exitCode = 1;
});

15
src/db.ts Normal file
View file

@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';
declare global {
// allow global `var` declarations
// eslint-disable-next-line no-var
var __prisma: PrismaClient | undefined;
}
export const prisma =
global.__prisma ||
new PrismaClient({
log: ['query'],
});
if (process.env.NODE_ENV !== 'production') global.__prisma = prisma;

30
src/ocr.ts Normal file
View file

@ -0,0 +1,30 @@
import { IArea } from "@bmunozg/react-image-area";
import Tesseract from "tesseract.js";
import { unPercentify } from "./utils";
export async function performOcr(image: HTMLImageElement, a: IArea): Promise<string> {
const area = a.unit === 'px' ? {
x: a.x,
y: a.y,
width: a.width,
height: a.height,
} : {
x: unPercentify(image.width, a.x),
y: unPercentify(image.height, a.y),
width: unPercentify(image.width, a.width),
height: unPercentify(image.height, a.height),
};
const canvas = document.createElement('canvas');
canvas.width = area.width;
canvas.height = area.height;
canvas.style.display = 'none';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(image, -area.x, -area.y);
const text = await Tesseract.recognize(canvas);
document.body.removeChild(canvas);
return text.data.text.trim();
}

54
src/utils.ts Normal file
View file

@ -0,0 +1,54 @@
const ONE_OVER_PHI = 0.618033988749895;
function hsvToRgb(h: number, s: number, v: number): string {
const h_i = Math.round(h*6);
const f = h*6 - h_i;
const p = v * (1 - s);
const q = v * (1 - f*s);
const t = v * (1 - (1 - f) * s);
let r: number, g: number, b: number;
if (h_i === 0) {
r = v;
g = t;
b = p;
} else if (h_i == 1) {
r = q;
g = v;
b = p;
} else if (h_i == 2) {
r = p;
g = v;
b = t;
} else if (h_i == 3) {
r = p;
g = q;
b = v;
} else if (h_i == 4) {
r = t;
g = p;
b = v;
} else {
r = v;
g = p;
b = q;
}
return `${Math.round(r*256)}, ${Math.round(g*256)}, ${Math.round(b*256)}`;
}
export function randomColour(index: number, s: number = 0.3, v: number = 0.99, opacity: number = 1) {
// Generated with Math.random()
let h = 0.6220694728604135;
for (var i = 0; i < index; i++) {
h += ONE_OVER_PHI;
h %= 1;
}
return `rgba(${hsvToRgb(h, s, v)}, ${opacity * 100}%)`;
}
export function unPercentify(max: number, v: number): number {
return Math.round((v / 100) * max);
}
export function percentify(max: number, v: number): number {
return (v / max) * 100;
}

View file

@ -1,116 +0,0 @@
.container {
padding: 0 2rem;
}
.main {
min-height: 100vh;
padding: 4rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.footer {
display: flex;
flex: 1;
padding: 2rem 0;
border-top: 1px solid #eaeaea;
justify-content: center;
align-items: center;
}
.footer a {
display: flex;
justify-content: center;
align-items: center;
flex-grow: 1;
}
.title a {
color: #0070f3;
text-decoration: none;
}
.title a:hover,
.title a:focus,
.title a:active {
text-decoration: underline;
}
.title {
margin: 0;
line-height: 1.15;
font-size: 4rem;
}
.title,
.description {
text-align: center;
}
.description {
margin: 4rem 0;
line-height: 1.5;
font-size: 1.5rem;
}
.code {
background: #fafafa;
border-radius: 5px;
padding: 0.75rem;
font-size: 1.1rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace;
}
.grid {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
max-width: 800px;
}
.card {
margin: 1rem;
padding: 1.5rem;
text-align: left;
color: inherit;
text-decoration: none;
border: 1px solid #eaeaea;
border-radius: 10px;
transition: color 0.15s ease, border-color 0.15s ease;
max-width: 300px;
}
.card:hover,
.card:focus,
.card:active {
color: #0070f3;
border-color: #0070f3;
}
.card h2 {
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
.card p {
margin: 0;
font-size: 1.25rem;
line-height: 1.5;
}
.logo {
height: 1em;
margin-left: 0.5rem;
}
@media (max-width: 600px) {
.grid {
width: 100%;
flex-direction: column;
}
}

View file

@ -1,16 +1,9 @@
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
a {
color: inherit;
text-decoration: none;
}
* {
box-sizing: border-box;
@layer base {
body {
@apply bg-gray-800 text-gray-200;
}
}

15
tailwind.config.js Normal file
View file

@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
maxWidth: {
'1/2': '50%',
},
},
},
plugins: [],
}

View file

@ -1,20 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}