mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-06-28 14:44:15 +00:00
feat: submit page qr code scanner
This commit is contained in:
parent
b35c0a53ea
commit
3df8a87c1c
7 changed files with 769 additions and 91 deletions
20
src/app/components/qr-finder.tsx
Normal file
20
src/app/components/qr-finder.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export default function QrFinder() {
|
||||
return (
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none size-72 z-10">
|
||||
{/* Top-left corner */}
|
||||
<div className="absolute top-0 left-0 size-6 border-t-3 border-l-3 border-amber-500 rounded-tl-lg" />
|
||||
|
||||
{/* Top-right corner */}
|
||||
<div className="absolute top-0 right-0 size-6 border-t-3 border-r-3 border-amber-500 rounded-tr-lg" />
|
||||
|
||||
{/* Bottom-left corner */}
|
||||
<div className="absolute bottom-0 left-0 size-6 border-b-3 border-l-3 border-amber-500 rounded-bl-lg" />
|
||||
|
||||
{/* Bottom-right corner */}
|
||||
<div className="absolute bottom-0 right-0 size-6 border-b-3 border-r-3 border-amber-500 rounded-br-lg" />
|
||||
|
||||
{/* Center point */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-5 bg-amber-500/70 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import CreatableSelect from "react-select/creatable";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
const options = [
|
||||
{ value: "anime", label: "anime" },
|
||||
{ value: "art", label: "art" },
|
||||
{ value: "cartoon", label: "cartoon" },
|
||||
{ value: "celebrity", label: "celebrity" },
|
||||
{ value: "games", label: "games" },
|
||||
{ value: "history", label: "history" },
|
||||
{ value: "meme", label: "meme" },
|
||||
{ value: "movie", label: "movie" },
|
||||
{ value: "oc", label: "oc" },
|
||||
{ value: "tv", label: "tv" },
|
||||
];
|
||||
import TagSelector from "./submit/tag-selector";
|
||||
import QrUpload from "./submit/qr-upload";
|
||||
import QrScanner from "./submit/qr-scanner";
|
||||
|
||||
export default function SubmitForm() {
|
||||
const { acceptedFiles, getRootProps, getInputProps } = useDropzone({
|
||||
|
|
@ -24,7 +15,8 @@ export default function SubmitForm() {
|
|||
},
|
||||
});
|
||||
|
||||
// todo: tag validating
|
||||
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
|
||||
const [qrBytes, setQrBytes] = useState<Uint8Array>(new Uint8Array());
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => e.preventDefault()} className="grid grid-cols-2">
|
||||
|
|
@ -68,94 +60,22 @@ export default function SubmitForm() {
|
|||
<label htmlFor="tags" className="font-semibold">
|
||||
Tags
|
||||
</label>
|
||||
<CreatableSelect
|
||||
isMulti
|
||||
placeholder="Select or create tags..."
|
||||
options={options}
|
||||
className="pill input col-span-2 w-full !py-0.5"
|
||||
styles={{
|
||||
control: (provided) => ({
|
||||
...provided,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
width: "100%",
|
||||
boxShadow: "none",
|
||||
}),
|
||||
valueContainer: (provided) => ({
|
||||
...provided,
|
||||
padding: "0",
|
||||
}),
|
||||
multiValue: (provided) => ({
|
||||
...provided,
|
||||
borderRadius: "16px",
|
||||
padding: "2px 8px",
|
||||
backgroundColor: "var(--color-orange-300)",
|
||||
}),
|
||||
multiValueRemove: (provided) => ({
|
||||
...provided,
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--color-black)",
|
||||
},
|
||||
}),
|
||||
indicatorsContainer: (provided) => ({
|
||||
...provided,
|
||||
"*": {
|
||||
padding: "1px",
|
||||
color: "black",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}),
|
||||
indicatorSeparator: () => ({
|
||||
display: "none",
|
||||
}),
|
||||
placeholder: (provided) => ({
|
||||
...provided,
|
||||
color: "rgba(0, 0, 0, 0.4)",
|
||||
}),
|
||||
menu: (provided) => ({
|
||||
...provided,
|
||||
backgroundColor: "var(--color-orange-200)",
|
||||
border: "2px solid var(--color-orange-400)",
|
||||
borderRadius: "8px",
|
||||
}),
|
||||
option: (provided, { isFocused }) => ({
|
||||
...provided,
|
||||
backgroundColor: isFocused ? "rgba(0, 0, 0, 0.15)" : "var(--color-orange-200)",
|
||||
cursor: "pointer",
|
||||
padding: "2px 8px",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<TagSelector />
|
||||
</div>
|
||||
|
||||
<fieldset className="border-t-2 border-b-2 border-black p-3 flex flex-col items-center gap-2">
|
||||
<legend className="px-2">QR Code</legend>
|
||||
|
||||
<div className="p-2 border-2 bg-orange-100 border-amber-500 rounded-2xl shadow-lg w-full">
|
||||
<div
|
||||
{...getRootProps({
|
||||
className:
|
||||
"bg-orange-100 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border border-2 border-dashed border-amber-500 select-none h-full",
|
||||
})}
|
||||
>
|
||||
<input {...getInputProps({ multiple: false })} />
|
||||
<Icon icon="material-symbols:upload" fontSize={48} />
|
||||
<p className="text-center text-sm">
|
||||
Drag and drop your QR code image here
|
||||
<br />
|
||||
or click to open
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<QrUpload />
|
||||
|
||||
<span>or</span>
|
||||
|
||||
<button className="pill button gap-2">
|
||||
<button onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2">
|
||||
<Icon icon="mdi:camera" fontSize={20} />
|
||||
Use your camera
|
||||
</button>
|
||||
|
||||
<QrScanner isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytes={setQrBytes} />
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" className="pill button w-min ml-auto">
|
||||
|
|
|
|||
79
src/app/components/submit/qr-scanner.tsx
Normal file
79
src/app/components/submit/qr-scanner.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { IDetectedBarcode, Scanner } from "@yudiel/react-qr-scanner";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import QrFinder from "../qr-finder";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setQrBytes: React.Dispatch<React.SetStateAction<Uint8Array>>;
|
||||
}
|
||||
|
||||
export default function QrScanner({ isOpen, setIsOpen, setQrBytes }: Props) {
|
||||
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ video: true })
|
||||
.then(() => setPermissionGranted(true))
|
||||
.catch(() => setPermissionGranted(false));
|
||||
}, [isOpen]);
|
||||
|
||||
const handleScan = (result: IDetectedBarcode[]) => {
|
||||
setIsOpen(false);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const byteArray = encoder.encode(result[0].rawValue);
|
||||
|
||||
setQrBytes(byteArray);
|
||||
};
|
||||
|
||||
if (isOpen)
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-40 backdrop-brightness-75 backdrop-blur-xs">
|
||||
<div className="bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">Scan QR Code</h2>
|
||||
<button onClick={() => setIsOpen(false)} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full aspect-square">
|
||||
{permissionGranted === null ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg border-2 border-amber-500">
|
||||
<div className="text-center p-4">
|
||||
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
|
||||
<p className="text-gray-600">Please allow camera access in your browser settings to scan QR codes</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Scanner
|
||||
formats={["qr_code"]}
|
||||
onScan={handleScan}
|
||||
components={{ finder: false }}
|
||||
sound={false}
|
||||
classNames={{ container: "rounded-lg border-2 border-amber-500" }}
|
||||
/>
|
||||
|
||||
<QrFinder />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button onClick={() => setIsOpen(false)} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
else return null;
|
||||
}
|
||||
31
src/app/components/submit/qr-upload.tsx
Normal file
31
src/app/components/submit/qr-upload.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
|
||||
export default function QrUpload() {
|
||||
const { acceptedFiles, getRootProps, getInputProps } = useDropzone({
|
||||
accept: {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".bmp", ".webp"],
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-2 border-2 bg-orange-100 border-amber-500 rounded-2xl shadow-lg w-full">
|
||||
<div
|
||||
{...getRootProps({
|
||||
className:
|
||||
"bg-orange-100 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border border-2 border-dashed border-amber-500 select-none h-full",
|
||||
})}
|
||||
>
|
||||
<input {...getInputProps({ multiple: false })} />
|
||||
<Icon icon="material-symbols:upload" fontSize={48} />
|
||||
<p className="text-center text-sm">
|
||||
Drag and drop your QR code image here
|
||||
<br />
|
||||
or click to open
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/app/components/submit/tag-selector.tsx
Normal file
83
src/app/components/submit/tag-selector.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"use client";
|
||||
|
||||
import CreatableSelect from "react-select/creatable";
|
||||
|
||||
const options = [
|
||||
{ value: "anime", label: "anime" },
|
||||
{ value: "art", label: "art" },
|
||||
{ value: "cartoon", label: "cartoon" },
|
||||
{ value: "celebrity", label: "celebrity" },
|
||||
{ value: "games", label: "games" },
|
||||
{ value: "history", label: "history" },
|
||||
{ value: "meme", label: "meme" },
|
||||
{ value: "movie", label: "movie" },
|
||||
{ value: "oc", label: "oc" },
|
||||
{ value: "tv", label: "tv" },
|
||||
];
|
||||
|
||||
export default function TagSelector() {
|
||||
// todo: tag validating
|
||||
|
||||
return (
|
||||
<CreatableSelect
|
||||
isMulti
|
||||
placeholder="Select or create tags..."
|
||||
options={options}
|
||||
className="pill input col-span-2 w-full !py-0.5"
|
||||
styles={{
|
||||
control: (provided) => ({
|
||||
...provided,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
width: "100%",
|
||||
boxShadow: "none",
|
||||
}),
|
||||
valueContainer: (provided) => ({
|
||||
...provided,
|
||||
padding: "0",
|
||||
}),
|
||||
multiValue: (provided) => ({
|
||||
...provided,
|
||||
borderRadius: "16px",
|
||||
padding: "2px 8px",
|
||||
backgroundColor: "var(--color-orange-300)",
|
||||
}),
|
||||
multiValueRemove: (provided) => ({
|
||||
...provided,
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--color-black)",
|
||||
},
|
||||
}),
|
||||
indicatorsContainer: (provided) => ({
|
||||
...provided,
|
||||
"*": {
|
||||
padding: "1px",
|
||||
color: "black",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}),
|
||||
indicatorSeparator: () => ({
|
||||
display: "none",
|
||||
}),
|
||||
placeholder: (provided) => ({
|
||||
...provided,
|
||||
color: "rgba(0, 0, 0, 0.4)",
|
||||
}),
|
||||
menu: (provided) => ({
|
||||
...provided,
|
||||
backgroundColor: "var(--color-orange-200)",
|
||||
border: "2px solid var(--color-orange-400)",
|
||||
borderRadius: "8px",
|
||||
}),
|
||||
option: (provided, { isFocused }) => ({
|
||||
...provided,
|
||||
backgroundColor: isFocused ? "rgba(0, 0, 0, 0.15)" : "var(--color-orange-200)",
|
||||
cursor: "pointer",
|
||||
padding: "2px 8px",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue