feat: image list in submit form

This commit is contained in:
trafficlunar 2025-04-06 22:47:09 +01:00
parent e83d7fadb5
commit a1fcd46bda
5 changed files with 168 additions and 9 deletions

View file

@ -11,6 +11,7 @@
},
"dependencies": {
"@auth/prisma-adapter": "2.7.2",
"@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.5.0",
"@trafficlunar/asmcrypto.js": "^1.0.2",
"@yudiel/react-qr-scanner": "2.2.2-beta.2",

View file

@ -11,6 +11,9 @@ importers:
'@auth/prisma-adapter':
specifier: 2.7.2
version: 2.7.2(@prisma/client@6.5.0(prisma@6.5.0(typescript@5.8.2))(typescript@5.8.2))
'@hello-pangea/dnd':
specifier: ^18.0.1
version: 18.0.1(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@prisma/client':
specifier: ^6.5.0
version: 6.5.0(prisma@6.5.0(typescript@5.8.2))(typescript@5.8.2)
@ -408,6 +411,12 @@ packages:
'@floating-ui/utils@0.2.9':
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
'@hello-pangea/dnd@18.0.1':
resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@ -903,6 +912,9 @@ packages:
'@types/react@19.1.0':
resolution: {integrity: sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@typescript-eslint/eslint-plugin@8.29.0':
resolution: {integrity: sha512-PAIpk/U7NIS6H7TEtN45SPGLQaHNgB7wSjsQV/8+KYokAb2T/gloOA/Bee2yd4/yKVhPKe5LlaUGhAZk5zmSaQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -1195,6 +1207,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
css-box-model@1.2.1:
resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@ -2062,6 +2077,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
raf-schd@4.0.3:
resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
react-dom@19.1.0:
resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==}
peerDependencies:
@ -2076,6 +2094,18 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-select@5.10.1:
resolution: {integrity: sha512-roPEZUL4aRZDx6DcsD+ZNreVl+fM8VsKn0Wtex1v4IazH60ILp5xhdlp464IsEAlJdXeD+BhDAFsBVMfvLQueA==}
peerDependencies:
@ -2092,6 +2122,9 @@ packages:
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
engines: {node: '>=0.10.0'}
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@ -2277,6 +2310,9 @@ packages:
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
engines: {node: '>=6'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinyglobby@0.2.12:
resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==}
engines: {node: '>=12.0.0'}
@ -2348,6 +2384,11 @@ packages:
'@types/react':
optional: true
use-sync-external-store@1.5.0:
resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
webrtc-adapter@9.0.1:
resolution: {integrity: sha512-1AQO+d4ElfVSXyzNVTOewgGT/tAomwwztX/6e3totvyyzXPvXIIuUUjAmyZGbKBKbZOXauuJooZm3g6IuFuiNQ==}
engines: {node: '>=6.0.0', npm: '>=3.10.0'}
@ -2686,6 +2727,18 @@ snapshots:
'@floating-ui/utils@0.2.9': {}
'@hello-pangea/dnd@18.0.1(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.0
css-box-model: 1.2.1
raf-schd: 4.0.3
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-redux: 9.2.0(@types/react@19.1.0)(react@19.1.0)(redux@5.0.1)
redux: 5.0.1
transitivePeerDependencies:
- '@types/react'
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.6':
@ -3069,6 +3122,8 @@ snapshots:
dependencies:
csstype: 3.1.3
'@types/use-sync-external-store@0.0.6': {}
'@typescript-eslint/eslint-plugin@8.29.0(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2)':
dependencies:
'@eslint-community/regexpp': 4.12.1
@ -3399,6 +3454,10 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
css-box-model@1.2.1:
dependencies:
tiny-invariant: 1.3.3
csstype@3.1.3: {}
damerau-levenshtein@1.0.8: {}
@ -4411,6 +4470,8 @@ snapshots:
queue-microtask@1.2.3: {}
raf-schd@4.0.3: {}
react-dom@19.1.0(react@19.1.0):
dependencies:
react: 19.1.0
@ -4425,6 +4486,15 @@ snapshots:
react-is@16.13.1: {}
react-redux@9.2.0(@types/react@19.1.0)(react@19.1.0)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.1.0
use-sync-external-store: 1.5.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.0
redux: 5.0.1
react-select@5.10.1(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/runtime': 7.27.0
@ -4453,6 +4523,8 @@ snapshots:
react@19.1.0: {}
redux@5.0.1: {}
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@ -4717,6 +4789,8 @@ snapshots:
tapable@2.2.1: {}
tiny-invariant@1.3.3: {}
tinyglobby@0.2.12:
dependencies:
fdir: 6.4.3(picomatch@4.0.2)
@ -4817,6 +4891,10 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.0
use-sync-external-store@1.5.0(react@19.1.0):
dependencies:
react: 19.1.0
webrtc-adapter@9.0.1:
dependencies:
sdp: 3.2.0

View file

@ -2,8 +2,8 @@
import { redirect } from "next/navigation";
import { useEffect, useState } from "react";
import { useDropzone } from "react-dropzone";
import { useCallback, useEffect, useState } from "react";
import { FileWithPath, useDropzone } from "react-dropzone";
import { Icon } from "@iconify/react";
import { AES_CCM } from "@trafficlunar/asmcrypto.js";
@ -16,11 +16,20 @@ import Mii from "@/utils/mii.js/mii";
import TomodachiLifeMii from "@/utils/tomodachi-life-mii";
import TagSelector from "./submit/tag-selector";
import ImageList from "./submit/image-list";
import QrUpload from "./submit/qr-upload";
import QrScanner from "./submit/qr-scanner";
export default function SubmitForm() {
const { acceptedFiles, getRootProps, getInputProps } = useDropzone({
const [files, setFiles] = useState<FileWithPath[]>([]);
const handleDrop = useCallback((acceptedFiles: FileWithPath[]) => {
setFiles((prev) => [...prev, ...acceptedFiles]);
}, []);
const { getRootProps, getInputProps } = useDropzone({
onDrop: handleDrop,
maxFiles: 3,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".bmp", ".webp"],
},
@ -154,14 +163,14 @@ export default function SubmitForm() {
/>
</div>
<div className="p-2 border-2 bg-orange-100 border-amber-500 rounded-2xl shadow-lg h-48">
<div className="p-2 border-2 bg-orange-200 border-amber-500 rounded-2xl shadow-lg h-48">
<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",
"bg-orange-200 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 })} />
<input {...getInputProps()} />
<Icon icon="material-symbols:upload" fontSize={64} />
<p className="text-center">
Drag and drop your images here
@ -171,7 +180,7 @@ export default function SubmitForm() {
</div>
</div>
{/* todo: show file list here */}
<ImageList files={files} setFiles={setFiles} />
</div>
<div className="p-4 flex flex-col gap-2">

View file

@ -0,0 +1,71 @@
import { FileWithPath } from "react-dropzone";
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
import { Icon } from "@iconify/react";
interface Props {
files: readonly FileWithPath[];
setFiles: React.Dispatch<React.SetStateAction<FileWithPath[]>>;
}
export default function ImageList({ files, setFiles }: Props) {
const handleDelete = (index: number) => {
const newFiles = [...files];
newFiles.splice(index, 1);
setFiles(newFiles);
};
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(files);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
setFiles(items);
};
return (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="imageDroppable">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps} className="flex flex-col px-12">
{files.map((file, index) => (
<Draggable key={file.name} draggableId={file.name} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className="w-full p-4 rounded-xl bg-orange-100 border-2 border-amber-500 flex gap-2 shadow-md my-1"
>
<img
src={URL.createObjectURL(file)}
alt={file.name}
className="aspect-[3/2] object-contain w-24 rounded-md bg-orange-300 border-2 border-orange-400"
/>
<div className="flex flex-col justify-center w-full min-w-0">
<span className="font-semibold text overflow-hidden text-ellipsis">{file.name}</span>
<button
onClick={() => handleDelete(index)}
className="pill button text-xs w-min !px-3 !py-1 !bg-red-300 !border-red-400 hover:!bg-red-400"
>
Delete
</button>
</div>
<div
{...provided.dragHandleProps}
className="h-full w-11 px-1 cursor-grab flex items-center justify-center rounded transition-colors hover:bg-black/10"
>
<Icon icon="tabler:grip-horizontal" className="size-6 text-black/50" />
</div>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
);
}

View file

@ -44,11 +44,11 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
});
return (
<div className="p-2 border-2 bg-orange-100 border-amber-500 rounded-2xl shadow-lg w-full">
<div className="p-2 border-2 bg-orange-200 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",
"bg-orange-200 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 })} />