Initial commit: any%
This commit is contained in:
commit
c938303a07
13 changed files with 4313 additions and 0 deletions
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
|
@ -0,0 +1,36 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
11
README.md
Normal file
11
README.md
Normal file
|
@ -0,0 +1,11 @@
|
|||
# qr.umm.gay
|
||||
|
||||
qr decoder web tool any% (20 minutes)
|
||||
|
||||
## Setup
|
||||
|
||||
```shell
|
||||
$ npm install # Install dependencies
|
||||
$ npm run dev # Start a local dev server
|
||||
$ npm run build # Generate static bundle in 'out/' directory
|
||||
```
|
155
lib/gluejar.ts
Normal file
155
lib/gluejar.ts
Normal file
|
@ -0,0 +1,155 @@
|
|||
import { useEffect, useCallback, useReducer } from "react";
|
||||
|
||||
/* <PasteContainer
|
||||
onPaste={files => files.length > 0 && this.props.onFileAccepted(files[0], files[0])}
|
||||
/> */
|
||||
|
||||
// NOTE: `onPaste` should return an array of files that were acceptedFiles
|
||||
//
|
||||
// const Image = ({ src }) => <img src={image} alt={`Pasted: ${image}`} />
|
||||
//
|
||||
// <PasteContainer
|
||||
// onPaste={files => this.method(files)}
|
||||
// onError={err => console.error(err)}
|
||||
// >
|
||||
// {({images}) => images.map((image, i) => <Image src={image} key={i} />)}
|
||||
// </PasteContainer>
|
||||
|
||||
const DEFAULT_ACCEPTED_FILES = ["image/gif", "image/png", "image/jpeg", "image/bmp"];
|
||||
|
||||
const _transformImageDataToURL = (
|
||||
data: DataTransfer,
|
||||
acceptedFiles: string[] = DEFAULT_ACCEPTED_FILES
|
||||
): string | undefined => {
|
||||
const isValidFormat = (fileType: string): boolean => acceptedFiles.includes(fileType);
|
||||
// NOTE: This needs to be a for loop, it's a list like object
|
||||
for (let i = 0; i < data.items.length; i++) {
|
||||
if (!isValidFormat(data.items[i].type)) {
|
||||
continue;
|
||||
// throw new Error(`Sorry, that's not a format we support ${data.items[i].type}`);
|
||||
}
|
||||
let blob: BlobLikeFile = data.items[i].getAsFile();
|
||||
|
||||
if (blob) {
|
||||
// We shouldn't fire the callback if we can't create `new Blob()`
|
||||
let file = window.URL.createObjectURL(blob);
|
||||
|
||||
return file;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
type BlobLikeFile = File | null;
|
||||
|
||||
export interface GlueJarOptions<T extends HTMLElement> {
|
||||
acceptedFiles: string[];
|
||||
ref?: React.RefObject<T>;
|
||||
}
|
||||
|
||||
export interface IGlueJarState {
|
||||
pasted: string[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export enum GlueActions {
|
||||
SET_FILE = "SET_FILES",
|
||||
SET_ERROR = "SET_ERROR",
|
||||
}
|
||||
|
||||
type GlueJarActions =
|
||||
| {
|
||||
type: GlueActions.SET_FILE;
|
||||
file: string;
|
||||
}
|
||||
| { type: GlueActions.SET_ERROR; error: string };
|
||||
|
||||
type GlueJarReducer = React.Reducer<IGlueJarState, GlueJarActions>;
|
||||
|
||||
const reducer: GlueJarReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case GlueActions.SET_FILE:
|
||||
return { ...state, pasted: [action.file, ...state.pasted], error: null };
|
||||
case GlueActions.SET_ERROR:
|
||||
return { ...state, error: action.error };
|
||||
default:
|
||||
throw new Error("Must specify action type");
|
||||
}
|
||||
};
|
||||
|
||||
const useGlueJarReducer = () =>
|
||||
useReducer<GlueJarReducer>(reducer, {
|
||||
pasted: [],
|
||||
error: null,
|
||||
});
|
||||
|
||||
export function usePasteHandler(acceptedFiles: string[] = DEFAULT_ACCEPTED_FILES) {
|
||||
const [state, dispatch] = useGlueJarReducer();
|
||||
|
||||
const transformImageDataToURL = useCallback(
|
||||
(data: DataTransfer): void => {
|
||||
try {
|
||||
const file = _transformImageDataToURL(data, acceptedFiles);
|
||||
dispatch(
|
||||
file
|
||||
? { type: GlueActions.SET_FILE, file }
|
||||
: {
|
||||
type: GlueActions.SET_ERROR,
|
||||
error: "Something went wrong",
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
dispatch({ type: GlueActions.SET_ERROR, error: error.message });
|
||||
}
|
||||
},
|
||||
[dispatch, acceptedFiles]
|
||||
);
|
||||
|
||||
const pasteHandler = useCallback(
|
||||
({ clipboardData }: ClipboardEvent): void => {
|
||||
if (clipboardData && clipboardData.items.length > 0) {
|
||||
transformImageDataToURL(clipboardData);
|
||||
} else {
|
||||
dispatch({
|
||||
type: GlueActions.SET_ERROR,
|
||||
error: `Sorry, to bother you but there was no image pasted.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
[dispatch, transformImageDataToURL]
|
||||
);
|
||||
|
||||
return [state, pasteHandler] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* useGlueJar
|
||||
* if you don't pass a `ref` to the options it will default to use the document
|
||||
* to add an event listener
|
||||
* @param options
|
||||
* @returns `IGlueJarState`
|
||||
*/
|
||||
export function useGlueJar<T extends HTMLElement>(
|
||||
{ ref, ...options }: Partial<GlueJarOptions<T>> = {
|
||||
acceptedFiles: DEFAULT_ACCEPTED_FILES,
|
||||
}
|
||||
) {
|
||||
const [state, pasteHandler] = usePasteHandler(options.acceptedFiles);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref && ref.current) {
|
||||
ref.current.addEventListener("paste", pasteHandler);
|
||||
} else {
|
||||
document.addEventListener("paste", pasteHandler);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (ref && ref.current) {
|
||||
ref.current.removeEventListener("paste", pasteHandler);
|
||||
} else {
|
||||
document.removeEventListener("paste", pasteHandler);
|
||||
}
|
||||
};
|
||||
}, [ref, options]);
|
||||
|
||||
return state;
|
||||
}
|
344
lib/qr.ts
Normal file
344
lib/qr.ts
Normal file
|
@ -0,0 +1,344 @@
|
|||
import { Options } from 'jsqr';
|
||||
import jsQR from 'jsqr';
|
||||
|
||||
const videoSize = {
|
||||
width: { min: 360, ideal: 720, max: 1080 },
|
||||
height: { min: 360, ideal: 720, max: 1080 },
|
||||
};
|
||||
|
||||
class QrcodeDecoder {
|
||||
timerCapture: null | NodeJS.Timeout;
|
||||
canvasElem: null | HTMLCanvasElement;
|
||||
gCtx: null | CanvasRenderingContext2D;
|
||||
stream: null | MediaStream;
|
||||
videoElem: null | HTMLVideoElement;
|
||||
getUserMediaHandler: null;
|
||||
videoConstraints: MediaStreamConstraints;
|
||||
defaultOption: Options;
|
||||
|
||||
constructor() {
|
||||
this.timerCapture = null;
|
||||
this.canvasElem = null;
|
||||
this.gCtx = null;
|
||||
this.stream = null;
|
||||
this.videoElem = null;
|
||||
this.getUserMediaHandler = null;
|
||||
this.videoConstraints = {
|
||||
// default use rear camera
|
||||
video: { ...videoSize, facingMode: { exact: 'environment' } },
|
||||
audio: false,
|
||||
};
|
||||
|
||||
this.defaultOption = { inversionAttempts: 'attemptBoth' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies if canvas element is supported.
|
||||
*/
|
||||
isCanvasSupported() {
|
||||
const elem = document.createElement('canvas');
|
||||
return !!(elem.getContext && elem.getContext('2d'));
|
||||
}
|
||||
|
||||
_createImageData(target: CanvasImageSource, width: number, height: number) {
|
||||
if (!this.canvasElem) {
|
||||
this._prepareCanvas(width, height);
|
||||
}
|
||||
|
||||
this.gCtx!.clearRect(0, 0, width, height);
|
||||
this.gCtx!.drawImage(target, 0, 0, width, height);
|
||||
|
||||
const imageData = this.gCtx!.getImageData(
|
||||
0,
|
||||
0,
|
||||
this.canvasElem!.width,
|
||||
this.canvasElem!.height,
|
||||
);
|
||||
|
||||
return imageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the canvas element (which will
|
||||
* receive the image from the camera and provide
|
||||
* what the algorithm needs for checking for a
|
||||
* QRCode and then decoding it.)
|
||||
*
|
||||
*
|
||||
* @param {DOMElement} canvasElem the canvas
|
||||
* element
|
||||
* @param {number} width The width that
|
||||
* the canvas element
|
||||
* should have
|
||||
* @param {number} height The height that
|
||||
* the canvas element
|
||||
* should have
|
||||
* @return {DOMElement} the canvas
|
||||
* after the resize if width and height
|
||||
* provided.
|
||||
*/
|
||||
_prepareCanvas(width: number, height: number) {
|
||||
if (!this.canvasElem) {
|
||||
this.canvasElem = document.createElement('canvas');
|
||||
this.canvasElem.style.width = `${width}px`;
|
||||
this.canvasElem.style.height = `${height}px`;
|
||||
this.canvasElem.width = width;
|
||||
this.canvasElem.height = height;
|
||||
}
|
||||
|
||||
this.gCtx = this.canvasElem.getContext('2d');
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on the video dimensions and the canvas
|
||||
* that was previously generated captures the
|
||||
* video/image source and then paints into the
|
||||
* canvas so that the decoder is able to work as
|
||||
* it expects.
|
||||
* @param {DOMElement} videoElem <video> dom element
|
||||
* @param {Object} options options (optional) - Additional options.
|
||||
* inversionAttempts - (attemptBoth (default), dontInvert, onlyInvert, or invertFirst)
|
||||
* refer to jsqr options: https://github.com/cozmo/jsQR
|
||||
*/
|
||||
async _captureToCanvas(videoElem: HTMLVideoElement, options: Options) {
|
||||
if (this.timerCapture) {
|
||||
clearTimeout(this.timerCapture);
|
||||
}
|
||||
const proms = () => {
|
||||
const p = new Promise(async (resolve) => {
|
||||
let code;
|
||||
if (videoElem.videoWidth && videoElem.videoHeight) {
|
||||
const imageData = this._createImageData(
|
||||
videoElem,
|
||||
videoElem.videoWidth,
|
||||
videoElem.videoHeight,
|
||||
);
|
||||
|
||||
code = jsQR(
|
||||
imageData.data,
|
||||
imageData.width,
|
||||
imageData.height,
|
||||
options,
|
||||
);
|
||||
|
||||
if (code) {
|
||||
resolve(code);
|
||||
} else {
|
||||
this.timerCapture = setTimeout(async () => {
|
||||
code = await this._captureToCanvas(videoElem, options);
|
||||
resolve(code);
|
||||
}, 500);
|
||||
}
|
||||
} else {
|
||||
this.timerCapture = setTimeout(async () => {
|
||||
code = await this._captureToCanvas(videoElem, options);
|
||||
resolve(code);
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return p;
|
||||
};
|
||||
|
||||
const result = await proms();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the video element for receiving
|
||||
* camera's input. Releases a stream if there
|
||||
* was any (resets).
|
||||
*
|
||||
* @param {DOMElement} videoElem <video> dom element
|
||||
* @param {Object} options options (optional) - Additional options.
|
||||
* inversionAttempts - (attemptBoth (default), dontInvert, onlyInvert, or invertFirst)
|
||||
* refer to jsqr options: https://github.com/cozmo/jsQR
|
||||
*/
|
||||
async decodeFromCamera(videoElem: HTMLVideoElement, options: any = {}) {
|
||||
const opts = {
|
||||
...this.defaultOption,
|
||||
...options,
|
||||
};
|
||||
|
||||
this.stop();
|
||||
if (!navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error("Couldn't get video from camera");
|
||||
}
|
||||
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia(this.videoConstraints);
|
||||
} catch (e) {
|
||||
if ((e as OverconstrainedError).name === 'OverconstrainedError') {
|
||||
console.log('[OverconstrainedError] Can not use rear camera.');
|
||||
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
...videoSize,
|
||||
...{
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
},
|
||||
},
|
||||
audio: false,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
videoElem.srcObject = stream;
|
||||
// videoElem.src = window.URL.createObjectURL(stream);
|
||||
this.videoElem = videoElem;
|
||||
this.stream = stream;
|
||||
|
||||
const code = await this.decodeFromVideo(videoElem, opts);
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the video element for video file.
|
||||
*
|
||||
* @param {DOMElement} videoElem <video> dom element
|
||||
* @param {Object} options options (optional) - Additional options.
|
||||
* inversionAttempts - (attemptBoth (default), dontInvert, onlyInvert, or invertFirst)
|
||||
* refer to jsqr options: https://github.com/cozmo/jsQR
|
||||
*/
|
||||
async decodeFromVideo(videoElem: HTMLVideoElement, options = {}) {
|
||||
const opts = {
|
||||
...this.defaultOption,
|
||||
...options,
|
||||
};
|
||||
try {
|
||||
this.videoElem = videoElem;
|
||||
const code = await this._captureToCanvas(videoElem, opts);
|
||||
return code;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes an image from its src.
|
||||
* @param {DOMElement} imageElem
|
||||
* @param {Object} options options (optional) - Additional options.
|
||||
* inversionAttempts - (attemptBoth (default), dontInvert, onlyInvert, or invertFirst)
|
||||
* refer to jsqr options: https://github.com/cozmo/jsQR
|
||||
*/
|
||||
async decodeFromImage(
|
||||
img: HTMLImageElement | string,
|
||||
options: { crossOrigin?: string } = {},
|
||||
) {
|
||||
let imgDom: HTMLImageElement | null = null;
|
||||
const opts = {
|
||||
...this.defaultOption,
|
||||
...options,
|
||||
};
|
||||
|
||||
if (typeof img === 'string') {
|
||||
imgDom = document.createElement('img');
|
||||
if (options.crossOrigin) {
|
||||
imgDom.crossOrigin = options.crossOrigin;
|
||||
}
|
||||
imgDom.src = img;
|
||||
const proms = () =>
|
||||
new Promise((resolve) => {
|
||||
imgDom!.onload = () => resolve(true);
|
||||
});
|
||||
await proms();
|
||||
} else if (+img.nodeType > 0) {
|
||||
if (!img.src) {
|
||||
throw new Error('The ImageElement must contain a src');
|
||||
}
|
||||
imgDom = img;
|
||||
}
|
||||
|
||||
let code = null;
|
||||
if (imgDom) {
|
||||
code = this._decodeFromImageElm(imgDom, opts);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
_decodeFromImageElm(imgObj: HTMLImageElement, options = {}) {
|
||||
const opts: Options = {
|
||||
...this.defaultOption,
|
||||
...options,
|
||||
};
|
||||
const imageData = this._createImageData(
|
||||
imgObj,
|
||||
imgObj.width,
|
||||
imgObj.height,
|
||||
);
|
||||
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height, opts);
|
||||
|
||||
if (code) {
|
||||
return code;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases a video stream that was being
|
||||
* captured by prepareToVideo
|
||||
*/
|
||||
stop() {
|
||||
if (this.stream) {
|
||||
const track = this.stream.getTracks()[0];
|
||||
track.stop();
|
||||
this.stream = null;
|
||||
|
||||
// fix: clear black bg after camera capture
|
||||
this.videoElem!.srcObject = null;
|
||||
}
|
||||
|
||||
if (this.timerCapture) {
|
||||
clearTimeout(this.timerCapture);
|
||||
this.timerCapture = null;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the sourceId for the camera to use.
|
||||
*/
|
||||
setGroupId(groupId: string) {
|
||||
if (groupId) {
|
||||
this.videoConstraints.video = {
|
||||
advanced: [{ groupId }],
|
||||
};
|
||||
} else {
|
||||
this.videoConstraints.video = true;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of all available video sources on
|
||||
* the current device.
|
||||
*/
|
||||
async getVideoDevices() {
|
||||
if (navigator.mediaDevices.enumerateDevices) {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
return devices.filter((item: MediaDeviceInfo) => {
|
||||
if (item.kind === 'videoinput') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
'Current browser doest not support MediaStreamTrack.getSources',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default QrcodeDecoder;
|
6
next.config.js
Normal file
6
next.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
3569
package-lock.json
generated
Normal file
3569
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
package.json
Normal file
26
package.json
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "qr-decoder",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build && next export",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@next/font": "13.1.6",
|
||||
"@types/node": "18.13.0",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"jsqr": "^1.4.0",
|
||||
"next": "13.1.6",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "8.34.0",
|
||||
"eslint-config-next": "13.1.6"
|
||||
}
|
||||
}
|
6
src/pages/_app.tsx
Normal file
6
src/pages/_app.tsx
Normal file
|
@ -0,0 +1,6 @@
|
|||
import '@/styles/globals.css'
|
||||
import type { AppProps } from 'next/app'
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />
|
||||
}
|
13
src/pages/_document.tsx
Normal file
13
src/pages/_document.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { Html, Head, Main, NextScript } from 'next/document'
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
}
|
67
src/pages/index.tsx
Normal file
67
src/pages/index.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import Head from 'next/head'
|
||||
|
||||
import { useGlueJar } from "../../lib/gluejar";
|
||||
import QrcodeDecoder from '../../lib/qr';
|
||||
import { useState } from 'react';
|
||||
import { QRCode } from 'jsqr';
|
||||
|
||||
function QRImage({ image }: { image: string }) {
|
||||
const [data, setData] = useState<QRCode>();
|
||||
const [errored, setErrored] = useState(false);
|
||||
|
||||
const qr = new QrcodeDecoder();
|
||||
qr.decodeFromImage(image).then(data => {
|
||||
if (data != null && typeof data === "object") {
|
||||
setData(data)
|
||||
} else {
|
||||
setErrored(true);
|
||||
}
|
||||
}).catch(err => {
|
||||
setData(undefined);
|
||||
setErrored(true);
|
||||
});
|
||||
|
||||
return <>
|
||||
<article>
|
||||
<img src={image} alt={"Pasted image: " + image} />
|
||||
{errored
|
||||
? <strong>There was an error decoding this QR code.</strong>
|
||||
: data === undefined
|
||||
? <p>Parsing...</p>
|
||||
: <code>{data.data}</code>}
|
||||
</article>
|
||||
</>
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { pasted, error } = useGlueJar();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="app">
|
||||
<Head>
|
||||
<title>QR Decoder</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</Head>
|
||||
<main>
|
||||
<header>
|
||||
<h1>QR Decoder</h1>
|
||||
<p>{"Paste an image :)"}</p>
|
||||
</header>
|
||||
|
||||
{error !== null && <span>{error}</span>}
|
||||
<div>
|
||||
{pasted.length > 0 &&
|
||||
pasted.map((image) => (
|
||||
<QRImage image={image} key={image} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
A web tool by <a href="https://som.codes">Charlotte Som</a>.
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
53
src/styles/globals.css
Normal file
53
src/styles/globals.css
Normal file
|
@ -0,0 +1,53 @@
|
|||
:root {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
article {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid black;
|
||||
border-radius: 6px;
|
||||
padding: 2em;
|
||||
margin-top: 2em;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 1.125rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
font-size: 0.85em;
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"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,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
Loading…
Reference in a new issue