TierList migration finished

This commit is contained in:
Dreamgineer
2025-10-17 16:12:17 -04:00 Unverified
parent fc97ebc35f
commit 38f8de1387
26 changed files with 763 additions and 901 deletions
+29
View File
@@ -0,0 +1,29 @@
"use server";
import { revalidatePath } from "next/cache";
import { cdnify } from "@/lib/api";
import { db } from "@/lib/db";
export async function fetchToCdn(urls: string[]) {
async function toFile(res: Response) {
return new File(
[await res.blob()],
res.headers
.get("Content-Disposition")
?.match(/^attachment;.*filename="([a-zA-Z0-9-_ .]+)"/)?.[1] ||
res.url.match(/\/([a-zA-Z0-9-_ .]+)$/)?.[1] ||
`file.${res.headers.get("Content-Type")?.split("/")[1] || "dat"}`,
);
}
return db
.transaction(async (tx) =>
Promise.all(
urls.map((url) =>
fetch(url, { redirect: "follow" })
.then(toFile)
.then((f) => cdnify(f, { tx })),
),
),
)
.finally(() => revalidatePath("/admin/cdn"));
}
+7 -3
View File
@@ -1,12 +1,16 @@
import { DataTable } from "@/components/tantable";
import { db } from "@/lib/db";
import { cdn } from "@/lib/db/schema";
import { cdnColumns } from "./table";
import { CdnTable } from "./table";
export default async function CdnManagerPage() {
const files = await db
.select({ id: cdn.id, name: cdn.name, size: cdn.size })
.from(cdn)
.orderBy(cdn.name);
return <DataTable columns={cdnColumns} data={files} />;
return (
<div className="m-2 ml-0">
<CdnTable files={files} />
</div>
);
}
+192 -2
View File
@@ -1,12 +1,59 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
import {
CloudUpload,
HardDriveDownload,
ReplaceAll,
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 { fetchToCdn } from "./api";
export const cdnColumns: ColumnDef<{
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,
},
{
accessorFn: (row) => row.name || `[${row.id}]`,
header: "Name",
@@ -17,6 +64,149 @@ export const cdnColumns: ColumnDef<{
},
];
export function CdnTable({
files,
}: {
files: {
id: string;
name: string | null;
size: string;
}[];
}) {
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: () => {
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({});
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: () => {
return `${urlList.length} URLs has been uploaded to CDN.`;
},
error: "Error fetching the files.",
});
}
return (
<div className="flex flex-col gap-2">
<div className="h-[calc(100svh-60px)] overflow-auto rounded-md bg-[#2225] backdrop-blur-sm border">
<DataTable
columns={columns}
data={files}
className={"border-none"}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
/>
</div>
<div className="flex justify-between h-9">
<div className="flex gap-2">
<SimpleTooltip text="Upload...">
<Button size="icon" onClick={() => fileInputRef.current?.click()}>
<CloudUpload />
</Button>
</SimpleTooltip>
<Dialog>
<DialogTrigger asChild>
<SimpleTooltip text="Fetch from URL...">
<Button size="icon" variant="outline">
<HardDriveDownload />
</Button>
</SimpleTooltip>
</DialogTrigger>
<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..."
value={urls}
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>
);
}
const b2s = (t: number) => {
let e = (Math.log2(t) / 10) | 0;
// biome-ignore lint/suspicious/noAssignInExpressions: copied
+34 -5
View File
@@ -1,6 +1,10 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { SidebarMenuButton } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
export function VersionCreateDialogForm() {
return <form>Todo</form>;
@@ -9,11 +13,11 @@ export function VersionCreateDialogForm() {
export function RandomWelcomeMessage({ list }: { list?: string[] }) {
const messages = list || [
"What are we cooking today?",
"What are we doing today?",
"I'm NOT an EMERGENCY FOOD!!",
"Why are you here?",
"The food is ready sir.",
"The emergency food is ready sir.",
"Who left the artifact stats like that?",
"Don’t forget to hydrate, boss.",
"Don't forget to hydrate, boss.",
"Another day, another reroll.",
"Still reviewing artifacts? Respect.",
"Let's make these builds shine.",
@@ -26,7 +30,7 @@ export function RandomWelcomeMessage({ list }: { list?: string[] }) {
"Your approval power is unmatched.",
"Remember: critique kindly, roast fairly.",
"Time to bless—or curse—some artifacts.",
"Don’t worry, no 5★ drama yet.",
"Don't worry, no 5★ drama yet.",
"Admin mode: activated.",
];
@@ -40,3 +44,28 @@ export function RandomWelcomeMessage({ list }: { list?: string[] }) {
return message;
}
export function SidebarLink({
className,
children,
href,
...props
}: React.ComponentProps<typeof Link>) {
const target = useMemo(
() =>
href
? new URL(href.toString(), "https://example.com").pathname
: undefined,
[href],
);
const pathname = usePathname();
return (
<Link href={href} {...props}>
<SidebarMenuButton
className={cn(target === pathname ? "bg-accent" : "", className)}
>
{children}
</SidebarMenuButton>
</Link>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client"; // Error boundaries must be Client Components
import { RefreshCw } from "lucide-react";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div className="h-full w-full m-2 ml-0 border bg-[#2225] backdrop-blur-md rounded-md flex flex-col gap-2 justify-center items-center">
<h2 className="font-bold text-2xl">Something went wrong!</h2>
<Button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
<RefreshCw />
Try again
</Button>
</div>
);
}
+32 -46
View File
@@ -48,7 +48,7 @@ import {
import { NavUser } from "@/components/user";
import { db } from "@/lib/db";
import { versions } from "@/lib/db/schema";
import { VersionCreateDialogForm } from "./client";
import { SidebarLink, VersionCreateDialogForm } from "./client";
export default async function AdminLayout({
children,
@@ -133,28 +133,22 @@ export default async function AdminLayout({
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<Link href="/admin/tl/ver">
<SidebarMenuButton>
<GitGraph />
Versions
</SidebarMenuButton>
</Link>
<SidebarLink href="/admin/tl/ver">
<GitGraph />
Versions
</SidebarLink>
</SidebarMenuItem>
<SidebarMenuItem>
<Link href="/admin/tl/badges">
<SidebarMenuButton>
<Badge />
Badges
</SidebarMenuButton>
</Link>
<SidebarLink href="/admin/tl/badges">
<Badge />
Badges
</SidebarLink>
</SidebarMenuItem>
<SidebarMenuItem>
<Link href="/admin/tl/layout">
<SidebarMenuButton>
<Columns3Cog />
Layout
</SidebarMenuButton>
</Link>
<SidebarLink href="/admin/tl/layout">
<Columns3Cog />
Layout
</SidebarLink>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
@@ -192,9 +186,9 @@ export default async function AdminLayout({
<SidebarMenu>
{vers.map((v) => (
<SidebarMenuItem key={v.id}>
<Link href={`/admin/ver/${v.id}`}>
<SidebarMenuButton>{v.name}</SidebarMenuButton>
</Link>
<SidebarLink href={`/admin/ver/${v.id}`}>
{v.name}
</SidebarLink>
</SidebarMenuItem>
))}
</SidebarMenu>
@@ -207,36 +201,28 @@ export default async function AdminLayout({
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<Link href="/admin/char">
<SidebarMenuButton>
<SquareUserRound />
Characters
</SidebarMenuButton>
</Link>
<SidebarLink href="/admin/char">
<SquareUserRound />
Characters
</SidebarLink>
</SidebarMenuItem>
<SidebarMenuItem>
<Link href="/admin/cdn">
<SidebarMenuButton>
<Package />
CDN
</SidebarMenuButton>
</Link>
<SidebarLink href="/admin/cdn">
<Package />
CDN
</SidebarLink>
</SidebarMenuItem>
<SidebarMenuItem className="mt-auto">
<Link href="/admin/log">
<SidebarMenuButton>
<ScrollText />
Audit Log
</SidebarMenuButton>
</Link>
<SidebarLink href="/admin/log">
<ScrollText />
Audit Log
</SidebarLink>
</SidebarMenuItem>
<SidebarMenuItem>
<Link href="/admin/settings">
<SidebarMenuButton>
<Settings />
Settings
</SidebarMenuButton>
</Link>
<SidebarLink href="/admin/settings">
<Settings />
Settings
</SidebarLink>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>