File chooser and revamps
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export async function CharacterCreatePage() {
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
interface CdnChooserContextType {
|
||||||
|
call(options?: { type?: string }): Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CdnChooserContext = createContext<CdnChooserContextType | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user