diff --git a/package-lock.json b/package-lock.json index e680d39c..61b264ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "next-auth": "^4.24.7", "next-nprogress-bar": "^2.3.11", "nextjs-toploader": "^1.6.12", + "qr-code-styling": "^1.9.1", "react": "^18.3.1", "react-data-table-component": "^7.6.2", "react-dom": "^18.3.1", @@ -7161,6 +7162,24 @@ "teleport": ">=0.2.0" } }, + "node_modules/qr-code-styling": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/qr-code-styling/-/qr-code-styling-1.9.1.tgz", + "integrity": "sha512-T/VxQchuZkQwYhIcyyMUmtXHPeDT6lJBYHfqGD5CBDyIjswxS7JZKf443q+SXO1K/9SUswi6JpXEUQ5AoMCpyg==", + "license": "MIT", + "dependencies": { + "qrcode-generator": "^1.4.4" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/qrcode-generator": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz", + "integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "dev": true, diff --git a/package.json b/package.json index e26da3c0..363c3195 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "next-auth": "^4.24.7", "next-nprogress-bar": "^2.3.11", "nextjs-toploader": "^1.6.12", + "qr-code-styling": "^1.9.1", "react": "^18.3.1", "react-data-table-component": "^7.6.2", "react-dom": "^18.3.1", diff --git a/src/app/(admin)/admin/link/_components/LinkFigure.tsx b/src/app/(admin)/admin/link/_components/LinkFigure.tsx index 07c4b930..770d04ee 100644 --- a/src/app/(admin)/admin/link/_components/LinkFigure.tsx +++ b/src/app/(admin)/admin/link/_components/LinkFigure.tsx @@ -1,8 +1,9 @@ "use client"; import { useEffect, useState } from "react"; -import { FaGlobeAsia } from "react-icons/fa"; +import { FaGlobeAsia, FaQrcode } from "react-icons/fa"; import { toast } from "sonner"; +import ClipboardJS from "clipboard"; import { deleteLink } from "@/actions/link"; import { H3, P } from "@/app/_components/global/Text"; @@ -18,12 +19,14 @@ import { UserIcon, } from "./Icons"; import Modal from "./Modal"; -import ClipboardJS from "clipboard"; +import QRModal from "./QRModal"; export default function LinkFigure({ link, }: Readonly<{ link: LinkWithCountAndUser }>) { const [isOpenModal, setIsOpenModal] = useState(false); + const [isOpenQRModal, setIsOpenQRModal] = useState(false); + const fullUrl = `https://go.moklet.org/${link.slug}`; useEffect(() => { const clipboard = new ClipboardJS(".copy"); @@ -50,72 +53,95 @@ export default function LinkFigure({ if (!result.error) toast.success(result.message, { id: toastId }); else toast.error(result.message, { id: toastId }); } + return ( -
+
{isOpenModal && } -
-
- - + {isOpenQRModal && ( + + )} + +
+
+ + -
-

+
+

- {"go.moklet.org/" + link.slug} + go.moklet.org/ + + {link.slug} +

-

{link.target_url}

-
- +

+ {link.target_url} +

+
+ -

{link.count?.click_count}

-

{stringifyDate(link.created_at)}

-

{link.user.name}

-

-
- - - + +
+ + + + + + + +
); diff --git a/src/app/(admin)/admin/link/_components/Modal.tsx b/src/app/(admin)/admin/link/_components/Modal.tsx index 9d34afda..5faa5856 100644 --- a/src/app/(admin)/admin/link/_components/Modal.tsx +++ b/src/app/(admin)/admin/link/_components/Modal.tsx @@ -12,11 +12,12 @@ import FormButton from "./part/SubmitButton"; export default function Modal({ setIsOpenModal, link, -}: { +}: Readonly<{ setIsOpenModal: Dispatch>; link?: LinkWithCountAndUser; -}) { +}>) { const [password, setPassword] = useState(!!link?.password); + async function update(formdata: FormData) { const toastId = toast.loading("Loading..."); const result = await updateLink(formdata); @@ -25,21 +26,25 @@ export default function Modal({ setIsOpenModal(false); } else toast.error(result.message, { id: toastId }); } + return ( -
-
-
+
+
+
-
-

Link Shortener

+
+

Link Shortener

-
+ +
+ + {password && ( )} - + + setPassword(!password)} /> - + +
-
+ +
diff --git a/src/app/(admin)/admin/link/_components/ModalCreate.tsx b/src/app/(admin)/admin/link/_components/ModalCreate.tsx index 0cf0c8b9..984ccd80 100644 --- a/src/app/(admin)/admin/link/_components/ModalCreate.tsx +++ b/src/app/(admin)/admin/link/_components/ModalCreate.tsx @@ -11,12 +11,13 @@ import FormButton from "./part/SubmitButton"; export default function ModalCreate({ setIsOpenModal, -}: { +}: Readonly<{ setIsOpenModal: Dispatch>; link?: LinkWithCountAndUser; -}) { +}>) { const ref = useRef(null); const [password, setPassword] = useState(false); + async function create(formdata: FormData) { const toastId = toast.loading("Loading..."); const result = await addLink(formdata); @@ -30,52 +31,64 @@ export default function ModalCreate({ } return ( -
-
-
+
+
+
-
-

Create Link

+
+

Create Link

-
+ +
+ + {password && ( )} - + + setPassword(!password)} /> - +
-
+ +
diff --git a/src/app/(admin)/admin/link/_components/QRModal.tsx b/src/app/(admin)/admin/link/_components/QRModal.tsx new file mode 100644 index 00000000..436226e8 --- /dev/null +++ b/src/app/(admin)/admin/link/_components/QRModal.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect, useRef, useState, ChangeEvent } from "react"; +import QRCodeStyling, { Options, FileExtension } from "qr-code-styling"; +import { defaultQROptions, fileExtensions } from "./part/qrOptions"; +import { H3, P } from "@/app/_components/global/Text"; +import { Button } from "@/app/_components/global/Button"; + +interface QRModalProps { + setIsOpenQRModal: (isOpen: boolean) => void; + url: string; +} + +export default function QRModal({ + setIsOpenQRModal, + url, +}: Readonly) { + const [options] = useState({ + ...defaultQROptions, + data: url, + }); + const [fileExt, setFileExt] = useState("svg"); + const [qrCode, setQrCode] = useState(); + const ref = useRef(null); + + useEffect(() => { + setQrCode(new QRCodeStyling(options)); + }, []); + + useEffect(() => { + if (ref.current) { + qrCode?.append(ref.current); + } + }, [qrCode, ref]); + + useEffect(() => { + if (!qrCode) return; + qrCode.update(options); + }, [qrCode, options]); + + const onExtensionChange = (event: ChangeEvent) => { + setFileExt(event.target.value as FileExtension); + }; + + const onDownloadClick = () => { + if (!qrCode) return; + qrCode.download({ + extension: fileExt, + name: `qr-${new URL(url).pathname.split("/").pop()}`, + }); + }; + + return ( +
+
+
+

QR Code

+ +
+ +
+
+
+ +
+
+

URL

+

{url}

+
+ +
+ + + +
+ + +
+
+
+ ); +} diff --git a/src/app/(admin)/admin/link/_components/part/qrOptions.ts b/src/app/(admin)/admin/link/_components/part/qrOptions.ts new file mode 100644 index 00000000..92eab4c6 --- /dev/null +++ b/src/app/(admin)/admin/link/_components/part/qrOptions.ts @@ -0,0 +1,31 @@ +import { Options, FileExtension } from "qr-code-styling"; + +export const defaultQROptions: Options = { + type: "svg", + shape: "square", + width: 500, + height: 500, + data: "https://www.moklet.org/berita/recapan-artikel-februari-2025--banyak-keseruan-sebelum-ramadhan-tiba", + margin: 0, + qrOptions: { + mode: "Byte", + errorCorrectionLevel: "Q", + }, + imageOptions: { + saveAsBlob: true, + hideBackgroundDots: true, + imageSize: 0.4, + margin: 0, + }, + dotsOptions: { + type: "rounded", + color: "#000000", + roundSize: true, + }, + backgroundOptions: { round: 0, color: "transparent" }, + image: "/logogram.png", + cornersSquareOptions: { type: "extra-rounded", color: "#000000" }, + cornersDotOptions: { type: "dot", color: "#000000" }, +}; + +export const fileExtensions: FileExtension[] = ["svg", "png", "jpeg", "webp"];