Compare commits

...

4 commits

Author SHA1 Message Date
e98cccc656
feat: experimental CI pipeline 2022-07-25 19:55:13 +01:00
36cf7171a8
chore: fix eslint warnings 2022-07-25 18:38:34 +01:00
0ae9d6efd8
chore: run prettier 2022-07-25 18:31:20 +01:00
d49926e55a
chore: add scripts for prettier 2022-07-25 18:28:29 +01:00
21 changed files with 779 additions and 394 deletions

36
.drone.yml Normal file
View file

@ -0,0 +1,36 @@
kind: pipeline
type: docker
name: Build
platform:
os: linux
arch: arm64
steps:
- name: Install dependencies
image: node-pnpm:16-alpine
commands:
- pnpm install --frozen-lockfile
- name: Check formatting
image: node-pnpm:16-alpine
commands:
- pnpm format:check
- name: Lint code
image: node-pnpm:16-alpine
commands:
- pnpm lint
- name: Build site
image: node-pnpm:16-alpine
commands:
- pnpm build
- name: Create bundle
image: alpine
commands:
- scripts/build-release.sh
- name: Push bundle
image: alpine
commands:
- apk add curl ca-certificates
- 'curl --fail --upload-file @comicbox.tar.gz --user robot:$ROBOT_TOKEN https://git.ashhhleyyy.dev/api/packages/robot/generic/comicbox/0.1.0+git.${DRONE_COMMIT_SHA:0:8}/comicbox.tar.gz'
trigger:
event: [push]
branch: [main]

View file

@ -1,3 +1,25 @@
{
"extends": "next/core-web-vitals"
"env": {
"browser": true,
"es2021": true
},
"extends": [
"next/core-web-vitals",
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/jsx-runtime"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["react", "@typescript-eslint"],
"rules": {
"@typescript-eslint/no-non-null-assertion": "off"
}
}

View file

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

View file

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

View file

@ -1,12 +1,39 @@
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
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(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
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(props as any)}
/>
);
}
}

View file

@ -3,10 +3,9 @@ const nextConfig = {
reactStrictMode: true,
swcMinify: true,
images: {
domains: [
'img.comicfury.com'
],
domains: ['img.comicfury.com'],
},
output: 'standalone',
};
module.exports = nextConfig;

View file

@ -6,7 +6,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@bmunozg/react-image-area": "^1.0.2",
@ -23,6 +25,7 @@
"@types/node": "18.0.6",
"@types/react": "18.0.15",
"@types/react-dom": "18.0.6",
"@typescript-eslint/eslint-plugin": "^5.31.0",
"autoprefixer": "^10.4.7",
"eslint": "8.20.0",
"eslint-config-next": "12.2.2",

View file

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

View file

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

View file

@ -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({});
}

View file

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

View file

@ -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,
}
}}>
&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' : '')}
<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>
&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,
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,
}
})
};
}),
},
};
}
};

View file

@ -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<
Record<string, never>,
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,
}
}
}
},
};
};

View file

@ -6,6 +6,7 @@ specifiers:
'@types/node': 18.0.6
'@types/react': 18.0.15
'@types/react-dom': 18.0.6
'@typescript-eslint/eslint-plugin': ^5.31.0
autoprefixer: ^10.4.7
eslint: 8.20.0
eslint-config-next: 12.2.2
@ -38,6 +39,7 @@ devDependencies:
'@types/node': 18.0.6
'@types/react': 18.0.15
'@types/react-dom': 18.0.6
'@typescript-eslint/eslint-plugin': 5.31.0_eslint@8.20.0+typescript@4.7.4
autoprefixer: 10.4.7_postcss@8.4.14
eslint: 8.20.0
eslint-config-next: 12.2.2_eslint@8.20.0+typescript@4.7.4
@ -466,6 +468,10 @@ packages:
resolution: {integrity: sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==}
dev: true
/@types/json-schema/7.0.11:
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
dev: true
/@types/json5/0.0.29:
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
dev: true
@ -503,6 +509,32 @@ packages:
/@types/scheduler/0.16.2:
resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==}
/@typescript-eslint/eslint-plugin/5.31.0_eslint@8.20.0+typescript@4.7.4:
resolution: {integrity: sha512-VKW4JPHzG5yhYQrQ1AzXgVgX8ZAJEvCz0QI6mLRX4tf7rnFfh5D8SKm0Pq6w5PyNfAWJk6sv313+nEt3ohWMBQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
'@typescript-eslint/parser': ^5.0.0
eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@typescript-eslint/scope-manager': 5.31.0
'@typescript-eslint/type-utils': 5.31.0_eslint@8.20.0+typescript@4.7.4
'@typescript-eslint/utils': 5.31.0_eslint@8.20.0+typescript@4.7.4
debug: 4.3.4
eslint: 8.20.0
functional-red-black-tree: 1.0.1
ignore: 5.2.0
regexpp: 3.2.0
semver: 7.3.7
tsutils: 3.21.0_typescript@4.7.4
typescript: 4.7.4
transitivePeerDependencies:
- supports-color
dev: true
/@typescript-eslint/parser/5.30.7_eslint@8.20.0+typescript@4.7.4:
resolution: {integrity: sha512-Rg5xwznHWWSy7v2o0cdho6n+xLhK2gntImp0rJroVVFkcYFYQ8C8UJTSuTw/3CnExBmPjycjmUJkxVmjXsld6A==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -531,11 +563,43 @@ packages:
'@typescript-eslint/visitor-keys': 5.30.7
dev: true
/@typescript-eslint/scope-manager/5.31.0:
resolution: {integrity: sha512-8jfEzBYDBG88rcXFxajdVavGxb5/XKXyvWgvD8Qix3EEJLCFIdVloJw+r9ww0wbyNLOTYyBsR+4ALNGdlalLLg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
'@typescript-eslint/types': 5.31.0
'@typescript-eslint/visitor-keys': 5.31.0
dev: true
/@typescript-eslint/type-utils/5.31.0_eslint@8.20.0+typescript@4.7.4:
resolution: {integrity: sha512-7ZYqFbvEvYXFn9ax02GsPcEOmuWNg+14HIf4q+oUuLnMbpJ6eHAivCg7tZMVwzrIuzX3QCeAOqKoyMZCv5xe+w==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: '*'
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@typescript-eslint/utils': 5.31.0_eslint@8.20.0+typescript@4.7.4
debug: 4.3.4
eslint: 8.20.0
tsutils: 3.21.0_typescript@4.7.4
typescript: 4.7.4
transitivePeerDependencies:
- supports-color
dev: true
/@typescript-eslint/types/5.30.7:
resolution: {integrity: sha512-ocVkETUs82+U+HowkovV6uxf1AnVRKCmDRNUBUUo46/5SQv1owC/EBFkiu4MOHeZqhKz2ktZ3kvJJ1uFqQ8QPg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dev: true
/@typescript-eslint/types/5.31.0:
resolution: {integrity: sha512-/f/rMaEseux+I4wmR6mfpM2wvtNZb1p9hAV77hWfuKc3pmaANp5dLAZSiE3/8oXTYTt3uV9KW5yZKJsMievp6g==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dev: true
/@typescript-eslint/typescript-estree/5.30.7_typescript@4.7.4:
resolution: {integrity: sha512-tNslqXI1ZdmXXrHER83TJ8OTYl4epUzJC0aj2i4DMDT4iU+UqLT3EJeGQvJ17BMbm31x5scSwo3hPM0nqQ1AEA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -557,6 +621,45 @@ packages:
- supports-color
dev: true
/@typescript-eslint/typescript-estree/5.31.0_typescript@4.7.4:
resolution: {integrity: sha512-3S625TMcARX71wBc2qubHaoUwMEn+l9TCsaIzYI/ET31Xm2c9YQ+zhGgpydjorwQO9pLfR/6peTzS/0G3J/hDw==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@typescript-eslint/types': 5.31.0
'@typescript-eslint/visitor-keys': 5.31.0
debug: 4.3.4
globby: 11.1.0
is-glob: 4.0.3
semver: 7.3.7
tsutils: 3.21.0_typescript@4.7.4
typescript: 4.7.4
transitivePeerDependencies:
- supports-color
dev: true
/@typescript-eslint/utils/5.31.0_eslint@8.20.0+typescript@4.7.4:
resolution: {integrity: sha512-kcVPdQS6VIpVTQ7QnGNKMFtdJdvnStkqS5LeALr4rcwx11G6OWb2HB17NMPnlRHvaZP38hL9iK8DdE9Fne7NYg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
dependencies:
'@types/json-schema': 7.0.11
'@typescript-eslint/scope-manager': 5.31.0
'@typescript-eslint/types': 5.31.0
'@typescript-eslint/typescript-estree': 5.31.0_typescript@4.7.4
eslint: 8.20.0
eslint-scope: 5.1.1
eslint-utils: 3.0.0_eslint@8.20.0
transitivePeerDependencies:
- supports-color
- typescript
dev: true
/@typescript-eslint/visitor-keys/5.30.7:
resolution: {integrity: sha512-KrRXf8nnjvcpxDFOKej4xkD7657+PClJs5cJVSG7NNoCNnjEdc46juNAQt7AyuWctuCgs6mVRc1xGctEqrjxWw==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -565,6 +668,14 @@ packages:
eslint-visitor-keys: 3.3.0
dev: true
/@typescript-eslint/visitor-keys/5.31.0:
resolution: {integrity: sha512-ZK0jVxSjS4gnPirpVjXHz7mgdOsZUHzNYSfTw2yPa3agfbt9YfqaBiBZFSSxeBWnpWkzCxTfUpnzA3Vily/CSg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
'@typescript-eslint/types': 5.31.0
eslint-visitor-keys: 3.3.0
dev: true
/acorn-jsx/5.3.2_acorn@8.7.1:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@ -1201,6 +1312,14 @@ packages:
string.prototype.matchall: 4.0.7
dev: true
/eslint-scope/5.1.1:
resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==}
engines: {node: '>=8.0.0'}
dependencies:
esrecurse: 4.3.0
estraverse: 4.3.0
dev: true
/eslint-scope/7.1.1:
resolution: {integrity: sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -1296,6 +1415,11 @@ packages:
estraverse: 5.3.0
dev: true
/estraverse/4.3.0:
resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==}
engines: {node: '>=4.0'}
dev: true
/estraverse/5.3.0:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
@ -1822,7 +1946,7 @@ packages:
dev: true
/ms/2.0.0:
resolution: {integrity: sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=}
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
dev: true
/ms/2.1.2:

View file

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

View file

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

View file

@ -2,21 +2,6 @@ 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',
@ -34,7 +19,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 +58,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 +81,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;
});

15
scripts/build-release.sh Executable file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env sh
set -e -u -o pipefail
echo Building production TAR...
mkdir -p comicbox/
mkdir -p comicbox/.next/
cp -r .next/standalone/* comicbox/
cp -r .next/static/ comicbox/.next/
cp next.config.js comicbox/
cp -r public/ comicbox/
tar -cvzf comicbox.tar.gz comicbox/

View file

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

View file

@ -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,17 @@ 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 = 0.3, v = 0.99, opacity = 1) {
// Generated with Math.random()
let h = 0.6220694728604135;
for (var i = 0; i < index; i++) {
h += ONE_OVER_PHI;
h %= 1;
for (let i = 0; i < index; i++) {
h += ONE_OVER_PHI;
h %= 1;
}
return `rgba(${hsvToRgb(h, s, v)}, ${opacity * 100}%)`;
}

View file

@ -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: [],
};