File chooser and revamps

This commit is contained in:
Dreamgineer
2025-12-17 15:35:44 +07:00 Unverified
parent 6ee9816a33
commit 904a8fe41d
22 changed files with 491 additions and 92 deletions
+27
View File
@@ -0,0 +1,27 @@
{
"show_edit_predictions": false,
"edit_predictions": {
"mode": "subtle"
},
"features": {
"edit_prediction_provider": "zed"
},
"minimap": {
"thumb": "hover",
"show": "auto"
},
"auto_signature_help": true,
"autosave": {
"after_delay": {
"milliseconds": 100
}
},
"ui_font_size": 16,
"buffer_font_size": 15,
"code_actions_on_format": {
"source.addMissingImports.ts": true,
"source.organizeImports.biome": true,
"source.fixAll.biome": true,
"source.removeUnusedImports": true
}
}
+38
View File
@@ -0,0 +1,38 @@
[
{
"label": "Development",
"command": "bun dev",
"use_new_terminal": false,
"allow_concurrent_runs": false,
"reveal": "never",
"hide": "on_success",
"shell": "system",
"show_summary": true,
"show_command": false,
"tags": ["dev"]
},
{
"label": "Deploy",
"command": "bun up",
"use_new_terminal": false,
"allow_concurrent_runs": false,
"reveal": "never",
"hide": "on_success",
"shell": "system",
"show_summary": true,
"show_command": false
},
{
"label": "Logs",
"command": "bun logs",
"use_new_terminal": false,
"allow_concurrent_runs": false,
"reveal": "no_focus",
"reveal_target": "center",
"hide": "always",
"shell": "system",
"show_summary": false,
"show_command": false,
"tags": ["dev"]
}
]
+17 -1
View File
@@ -1,11 +1,17 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { cdnify } from "@/lib/api"; import { actionLog, cdnify } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { cdn } from "@/lib/db/schema";
export async function fetchToCdn(urls: string[]) { export async function fetchToCdn(urls: string[]) {
if (!(await adminCheck())) throw "Unauthorized";
async function toFile(res: Response) { async function toFile(res: Response) {
if (!res.ok) throw "File failed to download";
return new File( return new File(
[await res.blob()], [await res.blob()],
res.headers res.headers
@@ -27,3 +33,13 @@ export async function fetchToCdn(urls: string[]) {
) )
.finally(() => revalidatePath("/admin/cdn")); .finally(() => revalidatePath("/admin/cdn"));
} }
export async function rename(id: string, name: string) {
if (!(await adminCheck())) throw "Unauthorized";
await db.update(cdn).set({ name }).where(eq(cdn.id, id));
await actionLog(`Renamed file to "${name}"`, { id });
revalidatePath("/admin/cdn");
}
+84 -8
View File
@@ -3,8 +3,12 @@
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table"; import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
import { import {
CloudUpload, CloudUpload,
Copy,
ExternalLink,
HardDriveDownload, HardDriveDownload,
Pencil,
ReplaceAll, ReplaceAll,
Trash,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { type ChangeEvent, useMemo, useRef, useState } from "react"; import { type ChangeEvent, useMemo, useRef, useState } from "react";
@@ -25,7 +29,8 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { cdnDelete, cdnify } from "@/lib/api"; import { cdnDelete, cdnify } from "@/lib/api";
import { fetchToCdn } from "./api"; import { cn } from "@/lib/utils";
import { fetchToCdn, rename } from "./api";
const columns: ColumnDef<{ const columns: ColumnDef<{
id: string; id: string;
@@ -70,12 +75,14 @@ const columns: ColumnDef<{
export function CdnTable({ export function CdnTable({
files, files,
onChoose,
}: { }: {
files: { files: {
id: string; id: string;
name: string | null; name: string | null;
size: string; size: string;
}[]; }[];
onChoose?: (value: string | null) => void;
}) { }) {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
@@ -139,14 +146,84 @@ export function CdnTable({
} }
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2 w-full">
<div className="h-[calc(100svh-60px)] overflow-auto rounded-md bg-[#2225] backdrop-blur-sm border"> <div
className={cn(
onChoose ? "h-[calc(70svh-60px)] w-full" : "h-[calc(100svh-60px)]",
"overflow-auto rounded-md bg-[#2225] backdrop-blur-sm border",
)}
>
<DataTable <DataTable
columns={columns} columns={columns}
data={files} data={files}
className={"border-none"} className={"border-none"}
rowSelection={rowSelection} rowSelection={rowSelection}
setRowSelection={setRowSelection} 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() {
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(", ")}`;
}
return "ลบสำเร็จ";
},
error(err: { message: string; description: string }) {
if (typeof err === "string") return err;
return "เกิดข้อผิดพลาดขณะลบ";
},
});
},
},
]}
onClick={(row) => onChoose?.(row.id)}
/> />
</div> </div>
@@ -158,13 +235,13 @@ export function CdnTable({
</Button> </Button>
</SimpleTooltip> </SimpleTooltip>
<Dialog> <Dialog>
<DialogTrigger asChild> <SimpleTooltip text="Fetch from URL...">
<SimpleTooltip text="Fetch from URL..."> <DialogTrigger asChild>
<Button size="icon" variant="outline"> <Button size="icon" variant="outline">
<HardDriveDownload /> <HardDriveDownload />
</Button> </Button>
</SimpleTooltip> </DialogTrigger>
</DialogTrigger> </SimpleTooltip>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Fetch From URLs</DialogTitle> <DialogTitle>Fetch From URLs</DialogTitle>
@@ -175,7 +252,6 @@ export function CdnTable({
</DialogHeader> </DialogHeader>
<Textarea <Textarea
placeholder="https://cdn.dgnr.us/paimon.png..." placeholder="https://cdn.dgnr.us/paimon.png..."
value={urls}
onChange={(ev) => setUrls(ev.target.value)} onChange={(ev) => setUrls(ev.target.value)}
/> />
<DialogFooter> <DialogFooter>
View File
+3 -45
View File
@@ -4,17 +4,6 @@ import { useMemo, useState } from "react";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import SearchBox, { type Filters, type ParsedQuery } from "@/components/search"; import SearchBox, { type Filters, type ParsedQuery } from "@/components/search";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { type characters, element } from "@/lib/db/schema"; import { type characters, element } from "@/lib/db/schema";
/** Filters and their value providers */ /** Filters and their value providers */
@@ -56,40 +45,9 @@ export function CharManager({
className="bg-input sticky top-2" className="bg-input sticky top-2"
onQueryChange={setQuery} onQueryChange={setQuery}
/> />
<Dialog> <Button className="h-10 w-10">
<form> <UserPlus />
<DialogTrigger asChild> </Button>
<Button className="h-10 w-10">
<UserPlus />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Character</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-3">
<Label htmlFor="name-1">Name</Label>
<Input id="name-1" name="name" />
</div>
<div className="grid gap-3">
<Label htmlFor="username-1">Username</Label>
<Input
id="username-1"
name="username"
defaultValue="@peduarte"
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit">Save changes</Button>
</DialogFooter>
</DialogContent>
</form>
</Dialog>
</div> </div>
<div className="flex flex-wrap gap-4 justify-center"> <div className="flex flex-wrap gap-4 justify-center">
{filteredChars.map((char) => ( {filteredChars.map((char) => (
+3
View File
@@ -0,0 +1,3 @@
export async function CharacterCreatePage() {
return
}
+11
View File
@@ -2,9 +2,11 @@
import { FolderSync } from "lucide-react"; import { FolderSync } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { CdnChooser } from "@/components/chooser";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { comms } from "@/lib/comms";
import { syncAmber, toggleEnka } from "./api"; import { syncAmber, toggleEnka } from "./api";
import { Section } from "./page"; import { Section } from "./page";
@@ -16,6 +18,7 @@ export function SettingsServicesSection({
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(""); const [syncResult, setSyncResult] = useState("");
const [enka, setEnka] = useState<boolean>(); const [enka, setEnka] = useState<boolean>();
const [debug] = comms.var("debug");
useEffect(() => { useEffect(() => {
if (typeof enka !== "boolean") return setEnka(enkaInitial); if (typeof enka !== "boolean") return setEnka(enkaInitial);
@@ -40,6 +43,14 @@ export function SettingsServicesSection({
{syncing ? <Spinner /> : <FolderSync />} {syncing ? <Spinner /> : <FolderSync />}
Sync Amber Sync Amber
</Button> </Button>
{debug && (
<div className="grid gap-1 rounded-sm border border-dashed p-1 w-min">
<span className="text-sm text-muted-foreground">
Test file input
</span>
<CdnChooser onChange={alert} className="w-50" />
</div>
)}
{syncResult && ( {syncResult && (
<pre className="border bg-muted rounded h-full p-2">{syncResult}</pre> <pre className="border bg-muted rounded h-full p-2">{syncResult}</pre>
)} )}
+1 -1
View File
@@ -82,7 +82,7 @@ export default function LoginPage({
<div className="bg-[#1117] backdrop-blur-lg flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"> <div className="bg-[#1117] backdrop-blur-lg flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<form onSubmit={submit}> <form onSubmit={submit} method="POST">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<a <a
+5 -2
View File
@@ -1,4 +1,4 @@
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray, isNull, or } from "drizzle-orm";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { import {
@@ -65,7 +65,10 @@ export default async function TierlistPage({
const badgesList = await tx const badgesList = await tx
.select() .select()
.from(tierlistBadges) .from(tierlistBadges)
.orderBy(tierlistBadges.order); .orderBy(tierlistBadges.order)
.where(
or(isNull(tierlistBadges.type), eq(tierlistBadges.type, version.type)),
);
const badges = badgesList.map((b) => ({ const badges = badgesList.map((b) => ({
...b, ...b,
tier: tiers.filter((t) => t.badges?.includes(b.id)).map((t) => t.id), tier: tiers.filter((t) => t.badges?.includes(b.id)).map((t) => t.id),
+2 -1
View File
@@ -1,4 +1,5 @@
import { and, eq, inArray, isNull, or } from "drizzle-orm"; import { and, eq, inArray, isNull, or } from "drizzle-orm";
import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { import {
@@ -16,7 +17,7 @@ export async function generateMetadata({
params, params,
}: { }: {
params: Promise<{ type: string; ver: string }>; params: Promise<{ type: string; ver: string }>;
}) { }): Promise<Metadata> {
const { type: typeId, ver: verId } = await params; const { type: typeId, ver: verId } = await params;
try { try {
+2
View File
@@ -2,6 +2,8 @@ import { sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { redis } from "@/lib/db/redis"; import { redis } from "@/lib/db/redis";
export const revalidate = 60;
export async function GET() { export async function GET() {
const red = await redis const red = await redis
.ping() .ping()
+4 -1
View File
@@ -6,6 +6,7 @@ import { useKey } from "react-use";
import ReconnectingEventSource from "reconnecting-eventsource"; import ReconnectingEventSource from "reconnecting-eventsource";
import { toast } from "sonner"; import { toast } from "sonner";
import { stringify } from "yaml"; import { stringify } from "yaml";
import { CdnChooserProvider } from "@/components/chooser";
import CommsProvider, { comms } from "@/lib/comms"; import CommsProvider, { comms } from "@/lib/comms";
export default function Providers({ children }: { children: React.ReactNode }) { export default function Providers({ children }: { children: React.ReactNode }) {
@@ -16,7 +17,9 @@ export default function Providers({ children }: { children: React.ReactNode }) {
options={{ showSpinner: false }} options={{ showSpinner: false }}
shallowRouting shallowRouting
> >
<CommsProvider>{children}</CommsProvider> <CdnChooserProvider>
<CommsProvider>{children}</CommsProvider>
</CdnChooserProvider>
</ProgressProvider> </ProgressProvider>
); );
} }
+62 -7
View File
@@ -1,11 +1,20 @@
"use client"; // Error boundaries must be Client Components "use client"; // Error boundaries must be Client Components
import { Activity, Home, RefreshCw } from "lucide-react"; import {
Activity,
ArrowLeftRight,
BookUser,
Database,
Home,
RefreshCw,
UserRoundSearch,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Qiqi from "#/assets/qiqi.webp"; import Qiqi from "#/assets/qiqi.webp";
import Background from "#/bg.jpg"; import Background from "#/bg.jpg";
import Image from "@/components/image"; import Image from "@/components/image";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
@@ -20,6 +29,12 @@ export default function ErrorPage({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [goingHome, goHome] = useState(false); const [goingHome, goHome] = useState(false);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [health, setHealth] = useState<{
database: boolean;
enka: boolean;
amber: boolean;
red: boolean;
}>();
useEffect(() => { useEffect(() => {
// Log the error to an error reporting service // Log the error to an error reporting service
@@ -44,6 +59,10 @@ export default function ErrorPage({
"บุสทำลายหน้านี้ไปแล้ว...", "บุสทำลายหน้านี้ไปแล้ว...",
]; ];
setMessage(messages[Math.floor(Math.random() * messages.length)]); setMessage(messages[Math.floor(Math.random() * messages.length)]);
fetch("/api/health")
.then((r) => r.json())
.then(setHealth);
}, [error]); }, [error]);
return ( return (
@@ -93,12 +112,48 @@ export default function ErrorPage({
</Button> </Button>
</div> </div>
</div> </div>
<Link href="https://status.sudloh.com" className="m-2"> <div className="flex gap-1 items-center">
<Button variant="link" className="text-secondary-foreground"> <Link href="https://status.sudloh.com" className="m-2 mr-0">
<Activity /> <Button variant="link" className="text-secondary-foreground">
สถานะเว็บไซต์ <Activity />
</Button> สถานะเว็บไซต์
</Link> </Button>
</Link>
<div className="h-6/10 w-px bg-border mr-2"></div>
{health ? (
<div className="flex gap-1 [&>svg]:size-4 [&>svg]:shrink-0 items-center">
<SimpleTooltip text="ฐานข้อมูล">
<Database
className={
health.database ? "text-emerald-400" : "text-red-400"
}
/>
</SimpleTooltip>
<SimpleTooltip text="Enka Network">
<UserRoundSearch
className={health.enka ? "text-emerald-400" : "text-red-400"}
/>
</SimpleTooltip>
<SimpleTooltip text="Project Amber">
<BookUser
className={health.amber ? "text-emerald-400" : "text-red-400"}
/>
</SimpleTooltip>
<SimpleTooltip text="ระบบซิงค์">
<ArrowLeftRight
className={health.red ? "text-emerald-400" : "text-red-400"}
/>
</SimpleTooltip>
</div>
) : (
<div className="flex gap-1 items-center">
<Skeleton className="size-4 rounded-xs" />
<Skeleton className="size-4 rounded-xs" />
<Skeleton className="size-4 rounded-xs" />
<Skeleton className="size-4 rounded-xs" />
</div>
)}
</div>
</div> </div>
</> </>
); );
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.2.7/schema.json", "$schema": "https://biomejs.dev/schemas/2.3.9/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",
+14
View File
@@ -0,0 +1,14 @@
"use server";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { cdn } from "@/lib/db/schema";
export async function listFiles() {
if (!(await adminCheck())) throw "Unauthorized";
return await db
.select({ id: cdn.id, name: cdn.name, size: cdn.size, type: cdn.type })
.from(cdn)
.orderBy(cdn.name);
}
+9
View File
@@ -0,0 +1,9 @@
import { createContext } from "react";
interface CdnChooserContextType {
call(options?: { type?: string }): Promise<string | null>;
}
export const CdnChooserContext = createContext<CdnChooserContextType | null>(
null,
);
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { Check, FileSearch2 } from "lucide-react";
import { type ComponentProps, useContext, useRef, useState } from "react";
import { CdnTable } from "@/app/(ui)/admin/cdn/table";
import type { cdn } from "@/lib/db/schema";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
import { Spinner } from "../ui/spinner";
import { listFiles } from "./api";
import { CdnChooserContext } from "./context";
export function CdnChooserProvider({
children,
}: {
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const [files, setFiles] = useState<
Omit<typeof cdn.$inferSelect, "data">[] | null
>(null);
const callback = useRef<((value: string | null) => void) | null>(null);
async function call(options?: { type?: string }): Promise<string | null> {
setOpen(true);
const list = await listFiles().catch(() => []);
setFiles(
options?.type
? list.filter((file) => file.type.startsWith(options.type!))
: list,
);
return new Promise<string | null>((resolve) => {
callback.current = resolve;
}).finally(() => setOpen(false));
}
return (
<CdnChooserContext value={{ call }}>
{children}
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) callback.current?.(null);
setOpen(o);
}}
>
<DialogContent className="max-h-[85vh] flex flex-col gap-4 md:max-w-[50svw]">
<DialogTitle className="px-6 pt-6">Choose File</DialogTitle>
{files ? (
<div className="flex-1 min-h-0 overflow-y-auto">
<CdnTable
files={files}
onChoose={(value) => callback.current?.(value)}
/>
</div>
) : (
<div className="flex h-[calc(70svh-60px)] w-full justify-center items-center">
<Spinner />
</div>
)}
</DialogContent>
</Dialog>
</CdnChooserContext>
);
}
export function CdnChooser({
className,
onChange,
type,
children,
defaultValue,
...props
}: ComponentProps<"input"> & {
onChange?: (value: string) => void;
type?: string;
}) {
const [value, setValue] = useState<string>(`${defaultValue || ""}`);
const chooser = useContext(CdnChooserContext);
async function choose() {
const res = await chooser?.call({ type });
if (res) {
setValue(res);
onChange?.(res);
}
}
return (
<>
<Button
onClick={choose}
className={cn(
"min-w-50 justify-between",
value && "bg-emerald-900!",
className,
)}
size="sm"
variant="outline"
>
{children || "Select File"}
{value ? <Check /> : <FileSearch2 />}
</Button>
<input value={value} hidden {...props} readOnly />
</>
);
}
+43 -11
View File
@@ -9,7 +9,7 @@ import {
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { FolderCode, Plus } from "lucide-react"; import { FolderCode, Plus } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { type MouseEventHandler, type ReactNode, useState } from "react";
import { import {
Table, Table,
TableBody, TableBody,
@@ -20,6 +20,12 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "./ui/context-menu";
import { import {
Empty, Empty,
EmptyContent, EmptyContent,
@@ -42,6 +48,8 @@ export function DataTable<TData, TValue>({
emptyDescription, emptyDescription,
rowSelection, rowSelection,
setRowSelection, setRowSelection,
context,
onClick,
}: DataTableProps<TData, TValue> & { }: DataTableProps<TData, TValue> & {
className?: string; className?: string;
openCreateDialog?: () => void; openCreateDialog?: () => void;
@@ -50,6 +58,12 @@ export function DataTable<TData, TValue>({
setRowSelection?: ( setRowSelection?: (
state: RowSelectionState | ((old: RowSelectionState) => RowSelectionState), state: RowSelectionState | ((old: RowSelectionState) => RowSelectionState),
) => void; ) => void;
context?: (row: TData) => {
label: string;
onClick?: MouseEventHandler<HTMLDivElement>;
icon?: ReactNode;
}[];
onClick?: (row: TData) => void;
}) { }) {
const [internalRowSelection, setInternalRowSelection] = useState({}); const [internalRowSelection, setInternalRowSelection] = useState({});
@@ -87,16 +101,34 @@ export function DataTable<TData, TValue>({
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow <ContextMenu key={row.id}>
key={row.id} <ContextMenuTrigger asChild>
data-state={row.getIsSelected() && "selected"} <TableRow
> data-state={row.getIsSelected() && "selected"}
{row.getVisibleCells().map((cell) => ( onClick={() => onClick?.(row.original)}
<TableCell key={cell.id}> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} {row.getVisibleCells().map((cell) => (
</TableCell> <TableCell key={cell.id}>
))} {flexRender(
</TableRow> cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent>
{context?.(row.original).map((option) => (
<ContextMenuItem
key={option.label}
onClick={option.onClick}
>
{option.icon}
{option.label}
</ContextMenuItem>
))}
</ContextMenuContent>
</ContextMenu>
)) ))
) : ( ) : (
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
+2
View File
@@ -30,6 +30,7 @@ services:
- 8095:3000 - 8095:3000
# - 80:3000 # - 80:3000
environment: environment:
- DISCORD_REDIRECT_URI=https://buzzy.sudloh.com/rubgram/callback
- BASE_URL=https://buzzy.sudloh.com - BASE_URL=https://buzzy.sudloh.com
deploy: deploy:
resources: resources:
@@ -47,6 +48,7 @@ services:
start_period: 5s start_period: 5s
nginx: nginx:
restart: always
image: nginx:stable-alpine image: nginx:stable-alpine
ports: ports:
- "8093:80" # Map host primary buzzy port to container port 80 - "8093:80" # Map host primary buzzy port to container port 80
+17 -14
View File
@@ -225,22 +225,25 @@ export async function cdnDelete(ids: string[], force = false) {
if (force) await db.delete(cdn).where(inArray(cdn.id, ids)); if (force) await db.delete(cdn).where(inArray(cdn.id, ids));
else else
for (const id of ids) { return await db.transaction(async (tx) => {
const refs = await checkCdnRefs(id); for (const id of ids) {
if (refs.length) { const refs = await checkCdnRefs(id, tx);
revalidatePath("/cdn/admin"); if (refs.length) {
await actionLog( revalidatePath("/cdn/admin");
`Deleted ${deleted}/${ids.length}(Incomplete) files`, if (deleted)
ids.slice(0, deleted), await actionLog(
); `Deleted ${deleted}/${ids.length}(Incomplete) files`,
return { id, refs }; ids.slice(0, deleted),
);
return { id, refs };
}
await tx.delete(cdn).where(eq(cdn.id, id));
deleted++;
} }
await db.delete(cdn).where(eq(cdn.id, id)); revalidatePath("/cdn/admin");
deleted++;
}
revalidatePath("/cdn/admin");
await actionLog(`Deleted ${deleted} files`, ids); await actionLog(`Deleted ${deleted} files`, ids);
});
} }
export async function checkCdnRefs( export async function checkCdnRefs(
+38
View File
@@ -0,0 +1,38 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { tierlistVersions } from "@/lib/db/schema";
const dat = await Bun.file("god.sql").text();
const matches = dat.matchAll(/^([a-z0-9-_]+)\t([a-zA-Z ]+)$/gm);
const mapping = Object.fromEntries(
[...matches].map(([_, old, n]) => [old, n.toLowerCase().replace(/ /g, "_")]),
);
// console.log(mapping);
const versions = await db
.select({ id: tierlistVersions.id, placements: tierlistVersions.placements })
.from(tierlistVersions);
for (const { placements } of versions) {
console.log({
placements: Object.fromEntries(
Object.entries(placements).map(([k, v]) => [k, v.map((c) => mapping[c])]),
),
});
}
await db.transaction(async (tx) => {
for (const { placements, id } of versions) {
await tx
.update(tierlistVersions)
.set({
placements: Object.fromEntries(
Object.entries(placements).map(([k, v]) => [
k,
v.map((c) => mapping[c]),
]),
),
})
.where(eq(tierlistVersions.id, id));
}
});