Files
buzz/app/(ui)/admin/cdn/table.tsx
T

308 lines
9.0 KiB
TypeScript
Executable File

"use client";
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
import {
CloudUpload,
Copy,
ExternalLink,
HardDriveDownload,
Pencil,
ReplaceAll,
Trash,
Trash2,
} from "lucide-react";
import { type ChangeEvent, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { DataTable } from "@/components/tantable";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { cdnDelete, cdnify } from "@/lib/api";
import { cn } from "@/lib/utils";
import { fetchToCdn, rename } from "./api";
const columns: ColumnDef<{
id: string;
name: string | null;
size: string;
}>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
meta: { className: "w-6" },
},
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "id",
header: "ID",
},
{
accessorFn: (row) => b2sClient(Number(row.size)),
header: "Size",
},
];
export function CdnTable({
files,
onChoose,
onChange,
}: {
files: {
id: string;
name: string | null;
size: string;
}[];
onChoose?: (
value: {
id: string;
name: string | null;
size: string;
} | null,
) => void;
onChange?: () => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [urls, setUrls] = useState("");
const selectedIds = useMemo(
() =>
Object.entries(rowSelection)
.filter((r) => r[1])
.map((r) => files[Number(r[0])].id),
[rowSelection, files],
);
async function upload(ev: ChangeEvent<HTMLInputElement>) {
const { files } = ev.target;
if (!files) return;
for (const file of files) {
toast.promise(cdnify(file), {
loading: `Uploading ${file.name}...`,
success: () => {
onChange?.();
return `${file.name} has been uploaded to CDN.`;
},
error: `Error uploading ${file.name}.`,
});
}
}
async function deleteSelected() {
toast.promise(cdnDelete(selectedIds), {
loading: `Delete ${selectedIds.length} files...`,
success: (res) => {
if (res) {
const file = files.find((f) => f.id === res.id);
toast.error(
`Error deleting ${file ? file.name || `[${res.id}]` : `[${res.id}]`}`,
{
description: `because it is still being used by ${res.refs.join(", ")}`,
},
);
throw "";
}
setRowSelection({});
onChange?.();
return `${selectedIds.length} files has been deleted.`;
},
error: `Error deleting the files.`,
});
}
async function fetchUrls() {
const urlList: string[] = urls
.toString()
.split("\n")
.filter((u) => u);
if (!urlList.length) return;
toast.promise(fetchToCdn(urlList), {
loading: `Fetching ${urlList.length} files...`,
success: () => {
onChange?.();
return `${urlList.length} URLs has been uploaded to CDN.`;
},
error: "Error fetching the files.",
});
}
return (
<div className="flex w-full flex-col gap-2">
<div
className={cn(
onChoose ? "h-[calc(70svh-60px)] w-full" : "h-[calc(100svh-60px)]",
"overflow-auto rounded-md border bg-[#2225] backdrop-blur-sm",
)}
>
<DataTable
columns={columns}
data={files}
className={"border-none"}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
context={(row) => [
{
label: "เปิดในแท็บใหม่",
icon: <ExternalLink />,
onClick() {
open(`/cdn/${row.id}`);
},
},
{
label: "ก็อปลิ้งค์",
icon: <Copy />,
onClick() {
toast.promise(
navigator.clipboard.writeText(
new URL(`/cdn/${row.id}`, location.href).href,
),
{
success() {
return "ก็อปลิ้งค์สำเร็จ";
},
error() {
return "เกิดข้อผิดพลาดขณะก็อปลิ้งค์";
},
},
);
},
},
{
label: "เปลี่ยนชื่อ",
icon: <Pencil />,
onClick() {
const newName = prompt("ชื่อไฟล์ใหม่", row.name ?? undefined);
if (!newName) return;
toast.promise(rename(row.id, newName), {
success() {
onChange?.();
return "เปลี่ยนชื่อสำเร็จ";
},
error() {
return "เกิดข้อผิดพลาดขณะเปลี่ยนชื่อ";
},
});
},
},
{
label: "ลบไฟล์",
icon: <Trash />,
onClick() {
toast.promise(cdnDelete([row.id]), {
success(res) {
if (res) {
const file = files.find((f) => f.id === res.id);
throw `${file ? file.name || `[${res.id}]` : `[${res.id}]`} ยังถูกใช้โดย ${res.refs.join(", ")}`;
}
onChange?.();
return "ลบสำเร็จ";
},
error(err: { message: string; description: string }) {
if (typeof err === "string") return err;
return "เกิดข้อผิดพลาดขณะลบ";
},
});
},
},
]}
onClick={(row) => onChoose?.(row)}
/>
</div>
<div className="flex h-9 justify-between">
<div className="flex gap-2">
<SimpleTooltip text="Upload...">
<Button size="icon" onClick={() => fileInputRef.current?.click()}>
<CloudUpload />
</Button>
</SimpleTooltip>
<Dialog>
<SimpleTooltip text="Fetch from URL...">
<DialogTrigger asChild>
<Button size="icon" variant="outline">
<HardDriveDownload />
</Button>
</DialogTrigger>
</SimpleTooltip>
<DialogContent>
<DialogHeader>
<DialogTitle>Fetch From URLs</DialogTitle>
<DialogDescription>
Enter URLs to be fetched and added to CDN below. One URL per
line.
</DialogDescription>
</DialogHeader>
<Textarea
placeholder="https://cdn.dgnr.us/paimon.png..."
onChange={(ev) => setUrls(ev.target.value)}
/>
<DialogFooter>
<DialogClose asChild>
<Button onClick={fetchUrls}>Fetch</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="flex gap-2">
<SimpleTooltip text="Replace File (WIP)">
<Button size="icon" variant="outline" disabled>
<ReplaceAll />
</Button>
</SimpleTooltip>
<SimpleTooltip text="Delete File">
<Button
size="icon"
variant="destructive"
disabled={selectedIds.length <= 0}
onClick={deleteSelected}
>
<Trash2 />
</Button>
</SimpleTooltip>
</div>
</div>
<div className="hidden">
<input type="file" multiple ref={fileInputRef} onChange={upload} />
</div>
</div>
);
}
export const b2sClient = (t: number) => {
let e = (Math.log2(t) / 10) | 0;
return `${(t / 1024 ** (e = e <= 0 ? 0 : e)).toFixed(1)}${" KMGP"[e]}B`;
};