commit 4cb356a3548df55ef405e2eecb764b6c3389750f Author: Khairul Hidayat Date: Fri May 26 15:51:14 2023 +0700 initial commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..cb3cb93 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23d67fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +yarn.lock diff --git a/index.html b/index.html new file mode 100644 index 0000000..fc8e0e3 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + License to Have Lolis Generator + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..633e4f2 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "loli-license-generator", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@chakra-ui/react": "^2.6.1", + "@emotion/react": "^11.11.0", + "@emotion/styled": "^11.11.0", + "dayjs": "^1.11.7", + "framer-motion": "^10.12.12", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-icons": "^4.8.0", + "zustand": "^4.3.8" + }, + "devDependencies": { + "@types/react": "^18.0.28", + "@types/react-dom": "^18.0.11", + "@typescript-eslint/eslint-plugin": "^5.57.1", + "@typescript-eslint/parser": "^5.57.1", + "@vitejs/plugin-react": "^4.0.0", + "eslint": "^8.38.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.3.4", + "typescript": "^5.0.2", + "vite": "^4.3.2" + } +} diff --git a/public/.DS_Store b/public/.DS_Store new file mode 100644 index 0000000..ee58f7e Binary files /dev/null and b/public/.DS_Store differ diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..6cc3bd4 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..8938d2b --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,78 @@ +import { + Box, + ChakraProvider, + IconButton, + Link, + Text, + useDisclosure, +} from "@chakra-ui/react"; +import AppBar from "./components/AppBar"; + +import { MdSave, MdEdit } from "react-icons/md"; +import EditDrawer from "./EditDrawer"; +import CardCanvas, { CardCanvasRef } from "./CardCanvas"; +import { useRef } from "react"; + +const App = () => { + const cardCanvasRef = useRef(null); + const editDrawer = useDisclosure(); + + return ( + + + + + + } + variant="ghost" + onClick={() => + cardCanvasRef.current && cardCanvasRef.current.onExport() + } + /> + } + variant="ghost" + onClick={editDrawer.onOpen} + /> + + } + /> + + { + cardCanvasRef.current = ref; + }} + /> + + + Artwork by:{" "} + + FLying Cookie + + + + + ); +}; + +export default App; diff --git a/src/CardCanvas.tsx b/src/CardCanvas.tsx new file mode 100644 index 0000000..76c8125 --- /dev/null +++ b/src/CardCanvas.tsx @@ -0,0 +1,142 @@ +import { Box } from "@chakra-ui/react"; +import { useEffect, useRef, useState } from "react"; +import cardBaseImage from "~/assets/base-card-bg.png"; +import { drawImageProp, loadImage, slugify, wrapText } from "./utils"; +import dayjs from "dayjs"; +import { useCardInfoStore } from "./stores/cardInfoStore"; + +export type CardCanvasRef = { + onExport: () => void; +}; + +type CardCanvasProps = { + onRef?: (ref: CardCanvasRef) => void; +}; + +type ImagesState = { + base: HTMLImageElement | null; + photo: HTMLImageElement | null; +}; + +const CardCanvas = ({ onRef }: CardCanvasProps) => { + const canvasRef = useRef(null); + const cardInfo = useCardInfoStore(); + const [images, setImages] = useState({ + base: null, + photo: null, + }); + + useEffect(() => { + loadImage(cardBaseImage).then((img) => { + setImages((i) => ({ ...i, base: img })); + }); + }, []); + + useEffect(() => { + const photoSrc = cardInfo.photo; + if (!photoSrc) { + return; + } + + loadImage(photoSrc).then((img) => { + setImages((i) => ({ ...i, photo: img })); + }); + }, [cardInfo.photo]); + + const redraw = (drawWatermark: boolean = false) => { + if (!canvasRef.current) { + return; + } + + const canvas = canvasRef.current; + const ctx = canvas.getContext("2d"); + + if (!ctx || !images.base) { + return; + } + + canvas.width = images.base.width; + canvas.height = images.base.height + (drawWatermark ? 12 : 0); + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.rect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "#f3f3f3"; + ctx.fill(); + + // Draw photo + if (images.photo) { + drawImageProp(ctx, images.photo, 640, 40, 172, 230); + } + + // Draw base card image + ctx.drawImage(images.base, 0, 0); + + ctx.fillStyle = "#111"; + ctx.font = "normal 24px Arial"; + + // left col + ctx.fillText(cardInfo.name, 190, 152); + ctx.fillText(cardInfo.idNo, 190, 220); + wrapText(ctx, cardInfo.accessLevel, 190, 290, 180, 28); + + // right col + ctx.fillText(cardInfo.sex, 418, 152); + ctx.fillText(dayjs(cardInfo.birthday).format("DD-MMM-YYYY"), 418, 220); + ctx.fillText("One at time", 418, 290); + + // footer + const expDate = dayjs().add(cardInfo.expYears, "year"); + ctx.fillText(dayjs(expDate).format("YYYYMMMDD").toUpperCase(), 320, 416); + + if (drawWatermark) { + ctx.fillStyle = "#333"; + ctx.font = "normal 16px Arial"; + ctx.fillText( + "Wanna join FBI watch list? s.id/loli-license", + 20, + canvas.height - 8 + ); + } + }; + + useEffect(() => { + redraw(); + }, [redraw, images, cardInfo]); + + const onExport = () => { + if (!canvasRef.current) { + return; + } + + redraw(cardInfo.showWatermarkOnExport); + + setTimeout(() => { + const link = document.createElement("a"); + link.download = `loli_license_${slugify(cardInfo.name)}.png`; + link.href = canvasRef.current!.toDataURL(); + link.click(); + redraw(false); + }, 100); + }; + + useEffect(() => { + if (onRef) { + onRef({ onExport }); + } + }, []); + + return ( + + + + ); +}; + +export default CardCanvas; diff --git a/src/EditDrawer.tsx b/src/EditDrawer.tsx new file mode 100644 index 0000000..1b84a59 --- /dev/null +++ b/src/EditDrawer.tsx @@ -0,0 +1,168 @@ +import { + Box, + Button, + Checkbox, + Drawer, + DrawerBody, + DrawerCloseButton, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerOverlay, + FormControl, + FormLabel, + Image, + Input, + Select, + VStack, +} from "@chakra-ui/react"; +import { MdEdit } from "react-icons/md"; +import { useCardInfoStore } from "./stores/cardInfoStore"; +import { useRef } from "react"; +import { fileToBase64 } from "./utils"; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +const EditDrawer = ({ isOpen, onClose }: Props) => { + const cardInfo = useCardInfoStore(); + const photoInputRef = useRef(null); + + const onImageChange = async (event: React.ChangeEvent) => { + if (!event.target.files?.length) { + return; + } + + const file = event.target.files[0]; + const data = await fileToBase64(file); + cardInfo.setValues({ photo: data }); + }; + + return ( + + + + + Card Detail + + + + + + + + Agent Name + cardInfo.setValues({ name: e.target.value })} + /> + + + + ID Number + cardInfo.setValues({ idNo: e.target.value })} + /> + + + + Sex + cardInfo.setValues({ sex: e.target.value })} + /> + + + + Birthday + + cardInfo.setValues({ birthday: e.target.value }) + } + /> + + + + Access Level + + cardInfo.setValues({ accessLevel: e.target.value }) + } + /> + + + + Expires in + + + + + cardInfo.setValues({ showWatermarkOnExport: e.target.checked }) + } + > + Show Watermark on Export + + + + + + + + + + ); +}; + +export default EditDrawer; diff --git a/src/assets/.DS_Store b/src/assets/.DS_Store new file mode 100644 index 0000000..7c24a16 Binary files /dev/null and b/src/assets/.DS_Store differ diff --git a/src/assets/base-card-bg.jpg b/src/assets/base-card-bg.jpg new file mode 100644 index 0000000..94ce029 Binary files /dev/null and b/src/assets/base-card-bg.jpg differ diff --git a/src/assets/base-card-bg.png b/src/assets/base-card-bg.png new file mode 100644 index 0000000..c5d800f Binary files /dev/null and b/src/assets/base-card-bg.png differ diff --git a/src/assets/mahirussy.jpg b/src/assets/mahirussy.jpg new file mode 100644 index 0000000..1a06758 Binary files /dev/null and b/src/assets/mahirussy.jpg differ diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/AppBar.tsx b/src/components/AppBar.tsx new file mode 100644 index 0000000..69db65d --- /dev/null +++ b/src/components/AppBar.tsx @@ -0,0 +1,40 @@ +import { HStack, Text } from "@chakra-ui/react"; +import { ReactNode } from "react"; + +type Props = { + title?: string; + actions?: ReactNode; +}; + +const AppBar = ({ title, actions }: Props) => { + return ( + + + {title} + + + {actions != null ? ( + + {actions} + + ) : null} + + ); +}; + +export default AppBar; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..64eacd7 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + +); diff --git a/src/stores/cardInfoStore.ts b/src/stores/cardInfoStore.ts new file mode 100644 index 0000000..21c9a34 --- /dev/null +++ b/src/stores/cardInfoStore.ts @@ -0,0 +1,53 @@ +import dayjs from "dayjs"; +import { create } from "zustand"; +import placeholderPhoto from "~/assets/mahirussy.jpg"; + +type CardInfoState = { + name: string; + idNo: string; + accessLevel: string; + sex: string; + birthday: string; + photo: string; + expYears: number; + showWatermarkOnExport: boolean; +}; + +type CardInfoStoreType = CardInfoState & { + setValues: (values: Partial) => void; +}; + +const getRandomAlphabet = (length: number = 1) => { + let res = ""; + const alpha = "abcdefghijklmnopqrstuvwxyz".toUpperCase(); + + for (let i = 0; i < length; i++) { + res += alpha.charAt(Math.round(Math.random() * alpha.length - 1)); + } + + return res; +}; + +const generateCardNumber = () => { + return ( + getRandomAlphabet() + + "-" + + getRandomAlphabet(2) + + Math.round(Math.random() * 9999) + .toString() + .padStart(4, "0") + ); +}; + +export const useCardInfoStore = create((set) => ({ + name: "Mahiro Oyama", + idNo: generateCardNumber(), + accessLevel: "Headpats and hugs only", + sex: "Male", + birthday: dayjs().format("YYYY-MM-DD"), + photo: placeholderPhoto, + expYears: 5, + showWatermarkOnExport: true, + + setValues: (values) => set({ ...values }), +})); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..ff177ec --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,141 @@ +/** + * Draw and wrap text in html canvas + * src: https://stackoverflow.com/a/27503574 + */ +export const wrapText = ( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + maxWidth: number, + lineHeight: number +) => { + var words = text.split(" "); + var line = ""; + + for (var n = 0; n < words.length; n++) { + var testLine = line + words[n] + " "; + var metrics = ctx.measureText(testLine); + var testWidth = metrics.width; + if (testWidth > maxWidth && n > 0) { + ctx.fillText(line, x, y); + line = words[n] + " "; + y += lineHeight; + } else { + line = testLine; + } + } + ctx.fillText(line, x, y); +}; + +/** + * Create slug from string + * src: https://gist.github.com/codeguy/6684588 + * @param str text to convert to + * @returns slug + */ +export const slugify = (str: string) => { + str = str.replace(/^\s+|\s+$/g, ""); // trim + str = str.toLowerCase(); + + // remove accents, swap ñ for n, etc + var from = "àáãäâèéëêìíïîòóöôùúüûñç·/_,:;"; + var to = "aaaaaeeeeiiiioooouuuunc------"; + + for (var i = 0, l = from.length; i < l; i++) { + str = str.replace(new RegExp(from.charAt(i), "g"), to.charAt(i)); + } + + str = str + .replace(/[^a-z0-9 -]/g, "") // remove invalid chars + .replace(/\s+/g, "-") // collapse whitespace and replace by - + .replace(/-+/g, "-"); // collapse dashes + + return str; +}; + +/** + * By Ken Fyrstenberg Nilsen + * https://stackoverflow.com/a/21961894 + * drawImageProp(context, image [, x, y, width, height [,offsetX, offsetY]]) + * + * If image and context are only arguments rectangle will equal canvas + */ +export const drawImageProp = ( + ctx: CanvasRenderingContext2D, + img: HTMLImageElement, + x: number, + y: number, + w: number, + h: number, + offsetX?: number, + offsetY?: number +) => { + // default offset is center + offsetX = typeof offsetX === "number" ? offsetX : 0.5; + offsetY = typeof offsetY === "number" ? offsetY : 0.5; + + // keep bounds [0.0, 1.0] + if (offsetX < 0) offsetX = 0; + if (offsetY < 0) offsetY = 0; + if (offsetX > 1) offsetX = 1; + if (offsetY > 1) offsetY = 1; + + var iw = img.width, + ih = img.height, + r = Math.min(w / iw, h / ih), + nw = iw * r, // new prop. width + nh = ih * r, // new prop. height + cx, + cy, + cw, + ch, + ar = 1; + + // decide which gap to fill + if (nw < w) ar = w / nw; + if (Math.abs(ar - 1) < 1e-14 && nh < h) ar = h / nh; // updated + nw *= ar; + nh *= ar; + + // calc source rectangle + cw = iw / (nw / w); + ch = ih / (nh / h); + + cx = (iw - cw) * offsetX; + cy = (ih - ch) * offsetY; + + // make sure source rectangle is valid + if (cx < 0) cx = 0; + if (cy < 0) cy = 0; + if (cw > iw) cw = iw; + if (ch > ih) ch = ih; + + // fill image in dest. rectangle + ctx.drawImage(img, cx, cy, cw, ch, x, y, w, h); +}; + +export const loadImage = (imgSrc: string): Promise => { + return new Promise((resolve) => { + const img = new Image(); + img.src = imgSrc; + img.onload = () => { + resolve(img); + }; + }); +}; + +export const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const fileReader = new FileReader(); + fileReader.readAsDataURL(file); + + fileReader.onload = () => { + resolve(fileReader.result as string); + }; + + fileReader.onerror = (error) => { + reject(error); + }; + }); +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4764699 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"], + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..75754a5 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "~": "/src", + }, + }, +});