feat: add mobile upload
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
// oxlint-disable next/no-img-element
|
||||
"use client";
|
||||
|
||||
import { Upload } from "lucide-react";
|
||||
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
import { CircularProgress } from "@/components/customized/progress/circular-progress";
|
||||
|
||||
export function PageFileInput({
|
||||
onValueChange,
|
||||
}: {
|
||||
onValueChange?: (value: File) => void;
|
||||
}) {
|
||||
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||
const submitButton = useRef<HTMLButtonElement | null>(null);
|
||||
const [value, setValue] = useState<File | undefined>();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const updateFormValue = useEffectEvent((file: File) => {
|
||||
onValueChange?.(file);
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!value) return;
|
||||
updateFormValue(value);
|
||||
setTimeout(() => submitButton.current?.click(), 100);
|
||||
setProgress(0);
|
||||
const start = Date.now();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const t = Math.min((Date.now() - start) / 10000, 1);
|
||||
const eased = 1 - Math.pow(1 - t, 2);
|
||||
|
||||
setProgress(eased * 100);
|
||||
|
||||
if (t >= 1) clearInterval(interval);
|
||||
}, 16);
|
||||
return () => clearInterval(interval);
|
||||
}, [value]);
|
||||
return (
|
||||
<div
|
||||
className="flex h-dvh bg-card/80 p-2"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<div className="flex w-full flex-col items-center justify-center gap-4 rounded-lg border-4 border-dashed select-none">
|
||||
{value ? (
|
||||
<>
|
||||
<CircularProgress
|
||||
circleStrokeWidth={12}
|
||||
labelClassName="text-xl font-bold"
|
||||
progressStrokeWidth={6}
|
||||
renderLabel={(progress) => `${progress}%`}
|
||||
showLabel
|
||||
size={120}
|
||||
value={Math.round(progress)}
|
||||
/>
|
||||
<div className="text-sm">กำลังอัพโหลด กรุณาอย่าปิดหน้านี้</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-full bg-primary/50 p-8">
|
||||
<Upload className="size-8" />
|
||||
</div>
|
||||
<div className="text-xl">แตะเพื่อเลือกไฟล์สลิป</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
accept="image/*"
|
||||
type="file"
|
||||
hidden
|
||||
ref={fileInput}
|
||||
onChange={() => setValue(fileInput.current?.files?.[0])}
|
||||
/>
|
||||
<button type="submit" ref={submitButton} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { db } from "@/lib/db";
|
||||
import type { TUploadKey } from "@/lib/db/schema";
|
||||
import { slipSync } from "@/lib/db/schema";
|
||||
import { sse } from "@/lib/db/sse-endpoints";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import z from "zod";
|
||||
import { PageFileInput } from "./client";
|
||||
import { FormInput, FormProvider } from "@/components/form";
|
||||
import { formParse } from "@/components/form-submit";
|
||||
|
||||
const Schema = z.object({
|
||||
slip: z.file(),
|
||||
});
|
||||
|
||||
export default async function DonateMobileSlipUploadPage({
|
||||
params,
|
||||
}: PageProps<"/donate/slip/[id]">) {
|
||||
const { id } = (await params) as { id: TUploadKey };
|
||||
const [{ trackingKey } = {}] = await db
|
||||
.select({
|
||||
trackingKey: slipSync.trackingKey,
|
||||
})
|
||||
.from(slipSync)
|
||||
.where(eq(slipSync.uploadKey, id));
|
||||
if (!trackingKey) redirect("/donate");
|
||||
sse.slip_sync.pub("connected", trackingKey);
|
||||
async function submit(data: FormData) {
|
||||
"use server";
|
||||
|
||||
const { $, error } = formParse(Schema, data);
|
||||
if (error) return { error };
|
||||
const { slip } = $;
|
||||
|
||||
await db
|
||||
.update(slipSync)
|
||||
.set({
|
||||
name: slip.name,
|
||||
type: slip.type,
|
||||
size: `${slip.size}`,
|
||||
data: Buffer.from(await slip.arrayBuffer()),
|
||||
})
|
||||
.where(eq(slipSync.uploadKey, id));
|
||||
|
||||
sse.slip_sync.pub("complete", trackingKey!);
|
||||
redirect("/donate/slip/complete");
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider id={`slip-upload-${id}`} onSubmit={submit} inDialog={false}>
|
||||
<FormInput name="slip">
|
||||
<PageFileInput />
|
||||
</FormInput>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
export default function PageFileInput() {
|
||||
return (
|
||||
<div className="flex h-dvh bg-card/80 p-2">
|
||||
<div className="flex w-full flex-col items-center justify-center gap-4 rounded-lg border-4 border-dashed select-none">
|
||||
<div className="rounded-full bg-emerald-500/50 p-8">
|
||||
<Check className="size-8" />
|
||||
</div>
|
||||
<div className="text-xl">อัพโหลดเสร็จเรียบร้อย ปิดหน้านี้ได้</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import type { TAccessKey } from "@/lib/db/schema";
|
||||
import { slipSync } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function startMobileUpload() {
|
||||
return await db
|
||||
.insert(slipSync)
|
||||
.values({})
|
||||
.returning()
|
||||
.then((r) => r.pop()!);
|
||||
}
|
||||
|
||||
export async function retrieveMobileUpload(accessKey: TAccessKey) {
|
||||
const object = await db
|
||||
.delete(slipSync)
|
||||
.where(eq(slipSync.accessKey, accessKey))
|
||||
.returning()
|
||||
.then((r) => r.pop()!);
|
||||
const fd = new FormData();
|
||||
fd.set(
|
||||
"file",
|
||||
new File([Buffer.from(object.data!)], object.name!, {
|
||||
type: object.type!,
|
||||
}),
|
||||
);
|
||||
fd.set("name", object.name!)
|
||||
return fd;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** biome-ignore-all lint/a11y/noStaticElementInteractions: yes maam */
|
||||
"use client";
|
||||
|
||||
import { FileSearch2, RefreshCw, X } from "lucide-react";
|
||||
import { FileSearch2, RefreshCw, TabletSmartphone, X } from "lucide-react";
|
||||
import { type ComponentProps, useEffect, useRef, useState } from "react";
|
||||
import { b2sClient } from "@/app/(ui)/admin/cdn/table";
|
||||
import { VirtualizedComboBox } from "@/components/combobox";
|
||||
@@ -26,6 +26,12 @@ import {
|
||||
} from "@/components/ui/multi-select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ActionButton } from "@/components/action-button";
|
||||
import { retrieveMobileUpload, startMobileUpload } from "./api";
|
||||
import { sse } from "@/lib/db/sse-endpoints";
|
||||
import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog";
|
||||
import QRCode from "react-qr-code";
|
||||
import { Status, StatusIndicator } from "@/components/ui/status";
|
||||
|
||||
export function CurrencyInput(props: ComponentProps<typeof Input>) {
|
||||
return (
|
||||
@@ -98,6 +104,11 @@ export function SlipUpload({
|
||||
}) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState<File | undefined | null>(externalValue);
|
||||
const [muUploadKey, setMuUploadKey] = useState<string>();
|
||||
const [muState, setMuState] = useState<"waiting" | "connected" | "completed">(
|
||||
"waiting",
|
||||
);
|
||||
const [muReject, setMuReject] = useState<(reason?: any) => void>(() => {});
|
||||
const isSelected = !!value?.name;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -114,8 +125,66 @@ export function SlipUpload({
|
||||
function choose() {
|
||||
ref.current?.click();
|
||||
}
|
||||
|
||||
async function mobileUpload() {
|
||||
// 1. create sync entry
|
||||
const { trackingKey, accessKey, uploadKey } = await startMobileUpload();
|
||||
|
||||
// 2. watch for slip_sync.complete for that tracking ID
|
||||
let completedResolve: () => void;
|
||||
const completedPromise = new Promise<void>(
|
||||
(r, j) => ((completedResolve = r), setMuReject(() => j)),
|
||||
);
|
||||
const ev = sse.slip_sync.subMany({
|
||||
complete(tid) {
|
||||
if (trackingKey !== tid) return;
|
||||
setMuState("completed");
|
||||
completedResolve();
|
||||
},
|
||||
connected(tid) {
|
||||
console.log(tid, trackingKey);
|
||||
if (trackingKey !== tid) return;
|
||||
setMuState("connected");
|
||||
},
|
||||
});
|
||||
|
||||
// 3. show qr/share dialog
|
||||
setMuUploadKey(uploadKey);
|
||||
// window.open(`http://localhost:3000/donate/slip/${uploadKey}`);
|
||||
|
||||
try {
|
||||
// 4. user open link on mobile
|
||||
// 5. user upload image
|
||||
// 6. image updates the sync entry and trigger slip_sync.complete
|
||||
await completedPromise;
|
||||
|
||||
// 7. pc client fetch image and delete the sync entry
|
||||
const fd = await retrieveMobileUpload(accessKey);
|
||||
const f = fd.get("file") as File;
|
||||
const file = new File([f], fd.get("name") as string, {
|
||||
type: f.type,
|
||||
lastModified: f.lastModified,
|
||||
});
|
||||
|
||||
// 8. pc client stop watch
|
||||
ev.clean();
|
||||
|
||||
// 9. close dialog and set slip input?
|
||||
setMuUploadKey(undefined);
|
||||
console.log(file);
|
||||
setValue(file);
|
||||
onValueChange?.(file);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setMuUploadKey(undefined);
|
||||
ev.clean();
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
onClick={isSelected ? undefined : () => ref.current?.click()}
|
||||
className={cn(
|
||||
@@ -149,6 +218,17 @@ export function SlipUpload({
|
||||
<FileSearch2 />
|
||||
)}
|
||||
</Button>
|
||||
<div className="-mt-1 hidden justify-end md:flex">
|
||||
<ActionButton
|
||||
action={mobileUpload}
|
||||
variant="link"
|
||||
className="text-primary-foreground"
|
||||
type="button"
|
||||
text="อัพโหลดสลิปจากมือถือ"
|
||||
>
|
||||
<TabletSmartphone />
|
||||
</ActionButton>
|
||||
</div>
|
||||
<input
|
||||
ref={ref}
|
||||
type="file"
|
||||
@@ -160,7 +240,53 @@ export function SlipUpload({
|
||||
onValueChange?.(file);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<Dialog
|
||||
open={!!muUploadKey}
|
||||
onOpenChange={(state) => state || muReject?.("user abort")}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader className="font-semibold">
|
||||
อัพโหลดสลิปจากมือถือ
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-5 py-10">
|
||||
<div className="rounded-xl bg-white p-4">
|
||||
<QRCode
|
||||
value={`https://buzz.sudloh.com/donate/slip/${muUploadKey}`}
|
||||
fgColor="#09090b"
|
||||
size={200}
|
||||
/>
|
||||
</div>
|
||||
<span className="max-w-2/3 text-center whitespace-pre-wrap">
|
||||
สแกนด้วยโทรศัพท์เพื่อเลือกสลิปจาก Gallery และอัพโหลดโดยตรงมาที่นี่
|
||||
</span>
|
||||
<span className="flex items-center justify-between rounded-full border bg-card p-1 pr-3">
|
||||
{muState === "waiting" ? (
|
||||
<>
|
||||
<Status status="degraded" className="bg-transparent">
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
กำลังรอการเชื่อมต่อ
|
||||
</>
|
||||
) : muState === "connected" ? (
|
||||
<>
|
||||
<Status status="maintenance" className="bg-transparent">
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
เชื่อมต่อแล้ว กำลังรออัพโหลดรูป
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Status status="online" className="bg-transparent">
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
กำลังดาวน์โหลดเข้าเครื่องนี
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user