feat: initial app implementation, including rain comic data
This commit is contained in:
parent
31db2b59cd
commit
a56c934854
38 changed files with 25150 additions and 256 deletions
6
.env.template
Normal file
6
.env.template
Normal 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"
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -33,3 +33,6 @@ yarn-error.log*
|
|||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# prisma
|
||||
prisma/dev.db*
|
||||
|
|
32
.prettierignore
Normal file
32
.prettierignore
Normal 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
8
.prettierrc
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"jsxSingleQuote": true,
|
||||
"semi": true,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true
|
||||
}
|
|
@ -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
16
components/Button.tsx
Normal 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
16
components/NavBar.tsx
Normal 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
12
components/TextInput.tsx
Normal 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} />
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
59
package.json
59
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
15
pages/api/auth/[...nextauth].ts
Normal file
15
pages/api/auth/[...nextauth].ts
Normal 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);
|
|
@ -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' });
|
||||
}
|
||||
|
|
89
pages/api/submit-page-bubbles.ts
Normal file
89
pages/api/submit-page-bubbles.ts
Normal 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({ });
|
||||
}
|
90
pages/comic/[comic]/index.tsx
Normal file
90
pages/comic/[comic]/index.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
361
pages/comic/[comic]/transcribe/[page].tsx
Normal file
361
pages/comic/[comic]/transcribe/[page].tsx
Normal 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,
|
||||
}
|
||||
}}>
|
||||
← 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,
|
||||
}
|
||||
}}>
|
||||
← 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 →
|
||||
</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,
|
||||
}
|
||||
})
|
||||
},
|
||||
};
|
||||
}
|
51
pages/comic/[comic]/transcribe/random.tsx
Normal file
51
pages/comic/[comic]/transcribe/random.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 →</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 →</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 →</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 →</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
2796
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
|
@ -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");
|
|
@ -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;
|
|
@ -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
|
||||
);
|
|
@ -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");
|
|
@ -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");
|
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
64
prisma/schema.prisma
Normal 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])
|
||||
}
|
4
prisma/seed-data/comics/rain-sources.txt
Normal file
4
prisma/seed-data/comics/rain-sources.txt
Normal 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
|
21173
prisma/seed-data/comics/rain.json
Normal file
21173
prisma/seed-data/comics/rain.json
Normal file
File diff suppressed because it is too large
Load diff
105
prisma/seed.ts
Normal file
105
prisma/seed.ts
Normal 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
15
src/db.ts
Normal 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
30
src/ocr.ts
Normal 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
54
src/utils.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
15
tailwind.config.js
Normal 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: [],
|
||||
}
|
|
@ -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"]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue