feat: add mobile upload
Build and Push Image / build-backend (push) Successful in 1m4s
Build and Push Image / build-frontend (push) Successful in 2m52s
Build and Push Image / push (push) Successful in 1m16s

This commit is contained in:
2026-08-29 15:32:47 +07:00 Unverified
parent 01da4c9d57
commit f7bd43087d
11 changed files with 501 additions and 6 deletions
+75
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
+14
View File
@@ -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;
}
+129 -3
View File
@@ -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>
);
}