chore: run prettier
This commit is contained in:
parent
d49926e55a
commit
0ae9d6efd8
16 changed files with 577 additions and 375 deletions
|
@ -1,16 +1,43 @@
|
|||
import { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps, RefAttributes } from "react";
|
||||
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} />
|
||||
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>;
|
||||
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>
|
||||
return (
|
||||
<Link {...props}>
|
||||
<a className={'text-blue-200 hover:underline ' + (className || '')}>
|
||||
{children}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
import { signIn, signOut, useSession } from 'next-auth/react';
|
||||
|
||||
export default function NavBar() {
|
||||
const { data, status } = useSession();
|
||||
|
@ -6,11 +6,15 @@ export default function NavBar() {
|
|||
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>
|
||||
</>}
|
||||
{status === 'unauthenticated' && (
|
||||
<button onClick={() => signIn('keycloak')}>Log in</button>
|
||||
)}
|
||||
{status === 'authenticated' && (
|
||||
<>
|
||||
<span>{data?.user?.name}</span>
|
||||
<button onClick={() => signOut()}>(Log out)</button>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,12 +1,37 @@
|
|||
import { DetailedHTMLProps, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||
import {
|
||||
DetailedHTMLProps,
|
||||
InputHTMLAttributes,
|
||||
TextareaHTMLAttributes,
|
||||
} from 'react';
|
||||
|
||||
type Props = ({ multiline: true } & DetailedHTMLProps<TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>)
|
||||
| ({ multiline: false | undefined } & DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>);
|
||||
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} />
|
||||
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} />
|
||||
return (
|
||||
<input
|
||||
className={
|
||||
'bg-slate-600 p-1 overflow-auto rounded ' + className
|
||||
}
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,9 +3,7 @@ const nextConfig = {
|
|||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
images: {
|
||||
domains: [
|
||||
'img.comicfury.com'
|
||||
],
|
||||
domains: ['img.comicfury.com'],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -4,12 +4,14 @@ import { SessionProvider } from 'next-auth/react';
|
|||
import NavBar from '../components/NavBar';
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return <>
|
||||
<SessionProvider>
|
||||
<NavBar />
|
||||
<Component {...pageProps} />
|
||||
</SessionProvider>
|
||||
</>;
|
||||
return (
|
||||
<>
|
||||
<SessionProvider>
|
||||
<NavBar />
|
||||
<Component {...pageProps} />
|
||||
</SessionProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyApp;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||
import Keycloak from "next-auth/providers/keycloak";
|
||||
import process from "process";
|
||||
import NextAuth, { NextAuthOptions } from 'next-auth';
|
||||
import Keycloak from 'next-auth/providers/keycloak';
|
||||
import process from 'process';
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
|
|
|
@ -1,32 +1,39 @@
|
|||
import { ComicBubble, Prisma } from "@prisma/client";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { unstable_getServerSession } from "next-auth";
|
||||
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]";
|
||||
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(),
|
||||
})),
|
||||
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' });
|
||||
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' });
|
||||
|
||||
if (!session)
|
||||
return res.status(401).json({ error: 'authentication required' });
|
||||
|
||||
const data = PageBubblesSchema.parse(req.body);
|
||||
|
||||
const page = await prisma.comicPage.findFirst({
|
||||
|
@ -46,7 +53,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
const char = await prisma.comicCharacter.findFirst({
|
||||
where: {
|
||||
id: bubble.character,
|
||||
}
|
||||
},
|
||||
});
|
||||
if (!char) {
|
||||
return res.status(400).json({ message: 'bad request' });
|
||||
|
@ -79,11 +86,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
areaHeight: bubble.area.height,
|
||||
characterId: bubble.character,
|
||||
content: bubble.text,
|
||||
}
|
||||
},
|
||||
});
|
||||
ops.push(op);
|
||||
}
|
||||
await prisma.$transaction(ops);
|
||||
|
||||
res.status(201).json({ });
|
||||
res.status(201).json({});
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
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";
|
||||
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;
|
||||
|
@ -14,47 +14,56 @@ interface Props {
|
|||
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>
|
||||
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>
|
||||
<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>
|
||||
<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 }) => {
|
||||
export const getServerSideProps: GetServerSideProps<Props, Params> = async ({
|
||||
params,
|
||||
}) => {
|
||||
const { comic: comicId } = params!;
|
||||
|
||||
const comic = await prisma.comic.findFirst({
|
||||
|
@ -83,8 +92,9 @@ export const getServerSideProps: GetServerSideProps<Props, Params> = async ({ pa
|
|||
comic,
|
||||
completion: {
|
||||
totalPages: pages.length,
|
||||
completePages: pages.filter(page => page._count.bubbles !== 0).length,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
completePages: pages.filter((page) => page._count.bubbles !== 0)
|
||||
.length,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,15 +1,19 @@
|
|||
import { AreaSelector, IArea, IAreaRendererProps } from "@bmunozg/react-image-area";
|
||||
import { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
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 { 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";
|
||||
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;
|
||||
|
@ -44,11 +48,16 @@ interface Region {
|
|||
|
||||
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>
|
||||
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);
|
||||
|
@ -59,25 +68,29 @@ export default function TranscribePage(props: Props) {
|
|||
|
||||
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],
|
||||
};
|
||||
}));
|
||||
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 props.characters.map((character) => {
|
||||
return {
|
||||
value: character.id,
|
||||
label: character.shortName,
|
||||
};
|
||||
})
|
||||
});
|
||||
}, [props.characters]);
|
||||
|
||||
async function submitBubbles() {
|
||||
|
@ -88,24 +101,39 @@ export default function TranscribePage(props: Props) {
|
|||
},
|
||||
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),
|
||||
};
|
||||
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,
|
||||
|
@ -118,162 +146,249 @@ export default function TranscribePage(props: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
return <main className='p-4'>
|
||||
<Head>
|
||||
<title>{`${props.page.title} - ${props.page.comic.title}`}</title>
|
||||
</Head>
|
||||
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' : '')}
|
||||
<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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<h3>Bubble {i + 1}</h3>
|
||||
← 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,
|
||||
0.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>
|
||||
<b>{region.character?.label}</b>: {region.text || '...'}
|
||||
<em>Click a bubble to edit</em>
|
||||
</p>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr className='border-slate-600 my-2' />
|
||||
{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);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button onClick={submitBubbles}>
|
||||
Save!
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<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 }) => {
|
||||
export const getServerSideProps: GetServerSideProps<Props, Params> = async ({
|
||||
params,
|
||||
}) => {
|
||||
const { comic: comicId, page: id } = params!;
|
||||
const pageId = parseInt(id);
|
||||
if (isNaN(pageId)) return { notFound: true };
|
||||
|
@ -329,8 +444,8 @@ export const getServerSideProps: GetServerSideProps<Props, Params> = async ({ pa
|
|||
select: {
|
||||
id: true,
|
||||
shortName: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -339,7 +454,7 @@ export const getServerSideProps: GetServerSideProps<Props, Params> = async ({ pa
|
|||
page,
|
||||
characters,
|
||||
key: `${page.comic.slug}-${page.id}`,
|
||||
regions: bubbles.map(bubble => {
|
||||
regions: bubbles.map((bubble) => {
|
||||
return {
|
||||
character: {
|
||||
value: bubble.character.id,
|
||||
|
@ -354,8 +469,8 @@ export const getServerSideProps: GetServerSideProps<Props, Params> = async ({ pa
|
|||
},
|
||||
index: bubble.idx,
|
||||
text: bubble.content,
|
||||
}
|
||||
})
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,43 +1,46 @@
|
|||
import { GetServerSideProps } from "next";
|
||||
import { ParsedUrlQuery } from "querystring";
|
||||
import { prisma } from "../../../../src/db";
|
||||
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...
|
||||
</>
|
||||
return <>Please wait...</>;
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<{}, Params> = async ({ params, query }) => {
|
||||
export const getServerSideProps: GetServerSideProps<{}, Params> = async ({
|
||||
params,
|
||||
query,
|
||||
}) => {
|
||||
const { comic: comicSlug } = params!;
|
||||
|
||||
const pages = await prisma.comicPage.findMany({
|
||||
where: {
|
||||
comic: {
|
||||
slug: comicSlug,
|
||||
const pages = await prisma.comicPage
|
||||
.findMany({
|
||||
where: {
|
||||
comic: {
|
||||
slug: comicSlug,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
_count: {
|
||||
select: {
|
||||
bubbles: true,
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}).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)];
|
||||
|
@ -46,6 +49,6 @@ export const getServerSideProps: GetServerSideProps<{}, Params> = async ({ param
|
|||
redirect: {
|
||||
destination: `/comic/${comicSlug}/transcribe/${randomPage.id}`,
|
||||
permanent: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -21096,7 +21096,7 @@
|
|||
"longName": "Brett Desrocher",
|
||||
"shortName": "Brett"
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
"longName": "Father Quenton Morrison",
|
||||
"shortName": "Father Quenton"
|
||||
|
@ -21133,7 +21133,7 @@
|
|||
"Frank Johnson",
|
||||
"Norman Strongwell",
|
||||
"Donna Strongwell",
|
||||
|
||||
|
||||
{
|
||||
"longName": "Heather Coven",
|
||||
"shortName": "Heather"
|
||||
|
|
|
@ -34,7 +34,7 @@ async function seedComicRain() {
|
|||
{
|
||||
// Chapters not directly related to the story
|
||||
const SKIP_CHAPTERS = [
|
||||
0, // Cover page
|
||||
0, // Cover page
|
||||
30, // SRS Hiatus
|
||||
37, // Summer 2018 Hiatus
|
||||
42, // Quarantine Hiatus 2020
|
||||
|
@ -73,14 +73,15 @@ async function seedComicRain() {
|
|||
{
|
||||
const ops: Prisma.Prisma__ComicCharacterClient<ComicCharacter>[] = [];
|
||||
for (const char of rain.characters) {
|
||||
const character = (typeof char === 'string')
|
||||
? { longName: char, shortName: char }
|
||||
: char;
|
||||
|
||||
const character =
|
||||
typeof char === 'string'
|
||||
? { longName: char, shortName: char }
|
||||
: char;
|
||||
|
||||
const characterData = {
|
||||
comicId: comic.id,
|
||||
...character
|
||||
}
|
||||
...character,
|
||||
};
|
||||
const op = prisma.comicCharacter.upsert({
|
||||
where: {
|
||||
longName: character.longName,
|
||||
|
@ -95,11 +96,8 @@ async function seedComicRain() {
|
|||
}
|
||||
|
||||
(async function () {
|
||||
|
||||
await seedComicRain();
|
||||
|
||||
})().catch(e => {
|
||||
console.error('failed to seed',
|
||||
e);
|
||||
})().catch((e) => {
|
||||
console.error('failed to seed', e);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
36
src/ocr.ts
36
src/ocr.ts
|
@ -1,19 +1,25 @@
|
|||
import { IArea } from "@bmunozg/react-image-area";
|
||||
import Tesseract from "tesseract.js";
|
||||
import { unPercentify } from "./utils";
|
||||
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),
|
||||
};
|
||||
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;
|
||||
|
|
21
src/utils.ts
21
src/utils.ts
|
@ -1,10 +1,10 @@
|
|||
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 h_i = Math.round(h * 6);
|
||||
const f = h * 6 - h_i;
|
||||
const p = v * (1 - s);
|
||||
const q = v * (1 - f*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) {
|
||||
|
@ -32,15 +32,22 @@ function hsvToRgb(h: number, s: number, v: number): string {
|
|||
g = p;
|
||||
b = q;
|
||||
}
|
||||
return `${Math.round(r*256)}, ${Math.round(g*256)}, ${Math.round(b*256)}`;
|
||||
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) {
|
||||
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;
|
||||
h += ONE_OVER_PHI;
|
||||
h %= 1;
|
||||
}
|
||||
return `rgba(${hsvToRgb(h, s, v)}, ${opacity * 100}%)`;
|
||||
}
|
||||
|
|
|
@ -1,15 +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%',
|
||||
},
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
maxWidth: {
|
||||
'1/2': '50%',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
plugins: [],
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue