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 */ /** biome-ignore-all lint/a11y/noStaticElementInteractions: yes maam */
"use client"; "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 { type ComponentProps, useEffect, useRef, useState } from "react";
import { b2sClient } from "@/app/(ui)/admin/cdn/table"; import { b2sClient } from "@/app/(ui)/admin/cdn/table";
import { VirtualizedComboBox } from "@/components/combobox"; import { VirtualizedComboBox } from "@/components/combobox";
@@ -26,6 +26,12 @@ import {
} from "@/components/ui/multi-select"; } from "@/components/ui/multi-select";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils"; 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>) { export function CurrencyInput(props: ComponentProps<typeof Input>) {
return ( return (
@@ -98,6 +104,11 @@ export function SlipUpload({
}) { }) {
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
const [value, setValue] = useState<File | undefined | null>(externalValue); 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; const isSelected = !!value?.name;
useEffect(() => { useEffect(() => {
@@ -114,8 +125,66 @@ export function SlipUpload({
function choose() { function choose() {
ref.current?.click(); 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 ( return (
<> <div className="flex flex-col">
<Button <Button
onClick={isSelected ? undefined : () => ref.current?.click()} onClick={isSelected ? undefined : () => ref.current?.click()}
className={cn( className={cn(
@@ -149,6 +218,17 @@ export function SlipUpload({
<FileSearch2 /> <FileSearch2 />
)} )}
</Button> </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 <input
ref={ref} ref={ref}
type="file" type="file"
@@ -160,7 +240,53 @@ export function SlipUpload({
onValueChange?.(file); 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>
); );
} }
+6 -2
View File
@@ -12,8 +12,12 @@ export function ActionButton({
action, action,
children, children,
onClick, onClick,
text,
...props ...props
}: ComponentProps<typeof Button> & { action: () => MaybePromise<void> }) { }: ComponentProps<typeof Button> & {
action: () => MaybePromise<void>;
text?: string;
}) {
const [state, setState] = useState<0 | 1 | 2>(0); const [state, setState] = useState<0 | 1 | 2>(0);
useEffect(() => { useEffect(() => {
@@ -38,7 +42,7 @@ export function ActionButton({
} }
}} }}
> >
{state === 0 ? children : state === 1 ? <Spinner /> : <Check />} {state === 0 ? children : state === 1 ? <Spinner /> : <Check />} {text}
</Button> </Button>
); );
} }
@@ -0,0 +1,88 @@
"use client";
import { cn } from "@/lib/utils";
interface CircularProgressProps {
value: number;
renderLabel?: (progress: number) => number | string;
size?: number;
strokeWidth?: number;
circleStrokeWidth?: number;
progressStrokeWidth?: number;
shape?: "square" | "round";
className?: string;
progressClassName?: string;
labelClassName?: string;
showLabel?: boolean;
}
export const CircularProgress = ({
value,
renderLabel,
className,
progressClassName,
labelClassName,
showLabel,
shape = "round",
size = 100,
strokeWidth,
circleStrokeWidth = 10,
progressStrokeWidth = 10,
}: CircularProgressProps) => {
const radius = size / 2 - 10;
const circumference = Math.ceil(3.14 * radius * 2);
const percentage = Math.ceil(circumference * ((100 - value) / 100));
const viewBox = `-${size * 0.125} -${size * 0.125} ${size * 1.25} ${
size * 1.25
}`;
return (
<div className="relative">
<svg
className="relative"
height={size}
style={{ transform: "rotate(-90deg)" }}
version="1.1"
viewBox={viewBox}
width={size}
xmlns="http://www.w3.org/2000/svg"
>
{/* Base Circle */}
<circle
className={cn("stroke-primary/25", className)}
cx={size / 2}
cy={size / 2}
fill="transparent"
r={radius}
strokeDasharray={circumference}
strokeDashoffset="0"
strokeWidth={strokeWidth ?? circleStrokeWidth}
/>
{/* Progress */}
<circle
className={cn("stroke-primary", progressClassName)}
cx={size / 2}
cy={size / 2}
fill="transparent"
r={radius}
strokeDasharray={circumference}
strokeDashoffset={percentage}
strokeLinecap={shape}
strokeWidth={strokeWidth ?? progressStrokeWidth}
/>
</svg>
{showLabel && (
<div
className={cn(
"absolute inset-0 flex items-center justify-center text-lg",
labelClassName,
)}
>
{renderLabel ? renderLabel(value) : value}
</div>
)}
</div>
);
};
+1
View File
@@ -234,6 +234,7 @@ export function FormProvider({
: typeof error === "string" : typeof error === "string"
? error ? error
: "Something went wrong"; : "Something went wrong";
if (message.includes("NEXT_REDIRECT")) return;
toast.error(message); toast.error(message);
} finally { } finally {
setLoading(false); setLoading(false);
+81
View File
@@ -0,0 +1,81 @@
"use client"
import type { ComponentProps, HTMLAttributes } from "react"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
export type StatusProps = ComponentProps<typeof Badge> & {
status: "online" | "offline" | "maintenance" | "degraded"
}
export const Status = ({ className, status, ...props }: StatusProps) => (
<Badge
className={cn("flex items-center gap-2", "group", status, className)}
variant="secondary"
{...(props as any)}
/>
)
export type StatusIndicatorProps = HTMLAttributes<HTMLSpanElement>
export const StatusIndicator = ({ className, ...props }: StatusIndicatorProps) => (
<span className={cn("relative flex h-2 w-2", className)} {...(props as any)}>
<span
className={cn(
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
"group-[.online]:bg-emerald-500",
"group-[.offline]:bg-red-500",
"group-[.maintenance]:bg-blue-500",
"group-[.degraded]:bg-amber-500",
)}
/>
<span
className={cn(
"relative inline-flex h-2 w-2 rounded-full",
"group-[.online]:bg-emerald-500",
"group-[.offline]:bg-red-500",
"group-[.maintenance]:bg-blue-500",
"group-[.degraded]:bg-amber-500",
)}
/>
</span>
)
export type StatusLabelProps = HTMLAttributes<HTMLSpanElement>
export const StatusLabel = ({ className, children, ...props }: StatusLabelProps) => (
<span className={cn("text-muted-foreground", className)} {...(props as any)}>
{children ?? (
<>
<span className="hidden group-[.online]:block">Online</span>
<span className="hidden group-[.offline]:block">Offline</span>
<span className="hidden group-[.maintenance]:block">Maintenance</span>
<span className="hidden group-[.degraded]:block">Degraded</span>
</>
)}
</span>
)
// Demo
export function Demo() {
return (
<div className="fixed inset-0 flex flex-wrap items-center justify-center gap-4">
<Status status="online">
<StatusIndicator />
<StatusLabel />
</Status>
<Status status="offline">
<StatusIndicator />
<StatusLabel />
</Status>
<Status status="maintenance">
<StatusIndicator />
<StatusLabel />
</Status>
<Status status="degraded">
<StatusIndicator />
<StatusLabel />
</Status>
</div>
)
}
+15
View File
@@ -366,6 +366,21 @@ export const donations = schDonate.table("donations", {
method: paymentMethod().notNull().default("pp"), method: paymentMethod().notNull().default("pp"),
}); });
export type TTrackingKey = string & { __brand: "tracking_key" };
export type TAccessKey = string & { __brand: "access_key" };
export type TUploadKey = string & { __brand: "upload_key" };
export const slipSync = schDonate.table("sync", {
trackingKey: text().primaryKey().$defaultFn(uuidv7).$type<TTrackingKey>(),
accessKey: text().notNull().$defaultFn(uuidv7).$type<TAccessKey>(),
uploadKey: text().notNull().$defaultFn(uuidv7).$type<TUploadKey>(),
data: bytea(),
name: text(),
type: text(),
size: text(),
});
//#endregion //#endregion
//#region Better Auth //#region Better Auth
+5 -1
View File
@@ -1,7 +1,7 @@
import z from "zod/v4"; import z from "zod/v4";
import type { YoutubeLiveInfo } from "@/app/api/live/route"; import type { YoutubeLiveInfo } from "@/app/api/live/route";
import { sseEndpoint, sseEndpointMap } from "./redis"; import { sseEndpoint, sseEndpointMap } from "./redis";
import type { auditLog, tierlistStates } from "./schema"; import type { auditLog, tierlistStates, TTrackingKey } from "./schema";
export const sse = sseEndpointMap({ export const sse = sseEndpointMap({
// Artifact Admin Listener // Artifact Admin Listener
@@ -54,6 +54,10 @@ export const sse = sseEndpointMap({
log: { log: {
update: z.custom<typeof auditLog.$inferSelect>(), update: z.custom<typeof auditLog.$inferSelect>(),
}, },
slip_sync: {
complete: z.custom<TTrackingKey>(),
connected: z.custom<TTrackingKey>()
},
}); });
export const adminSseList: (keyof typeof sse)[] = ["log"]; export const adminSseList: (keyof typeof sse)[] = ["log"];
export function tlSse<T extends string>(list: T) { export function tlSse<T extends string>(list: T) {