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 */
|
/** 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user