feat: admin delete

feat: total usage stats
This commit is contained in:
2024-12-16 12:52:27 +00:00
parent b30b90cb41
commit 961a6910ac
13 changed files with 228 additions and 63 deletions

View File

@ -22,7 +22,7 @@ export default function Button({
}
return (
<button
className={`py-2 px-4 rounded-md border-0 text-sm font-semibold bg-neutral-600 hover:bg-neutral-500 ${className} ${props.disabled ? "opacity-50" : ""}`}
className={`py-2 px-4 rounded-md border-0 text-sm font-semibold bg-neutral-700 hover:bg-neutral-600 ${className} ${props.disabled ? "opacity-50" : ""}`}
onClick={doClick}
{...props}
disabled={loading || (props.disabled ?? false)}

View File

@ -4,5 +4,9 @@
html,
body {
@apply bg-neutral-900 text-white;
@apply bg-black text-white;
}
hr {
@apply border-neutral-500
}

View File

@ -2,6 +2,8 @@ import { base64 } from "@scure/base";
import { throwIfOffline } from "@snort/shared";
import { EventKind, EventPublisher, NostrEvent } from "@snort/system";
export interface AdminSelf { is_admin: boolean, file_count: number, total_size: number }
export class Route96 {
constructor(
readonly url: string,
@ -11,15 +13,15 @@ export class Route96 {
}
async getSelf() {
const rsp = await this.#req("/admin/self", "GET");
const rsp = await this.#req("admin/self", "GET");
const data =
await this.#handleResponse<AdminResponse<{ is_admin: boolean }>>(rsp);
await this.#handleResponse<AdminResponse<AdminSelf>>(rsp);
return data;
}
async listFiles(page = 0, count = 10) {
const rsp = await this.#req(
`/admin/files?page=${page}&count=${count}`,
`admin/files?page=${page}&count=${count}`,
"GET",
);
const data = await this.#handleResponse<AdminResponseFileList>(rsp);

View File

@ -25,7 +25,7 @@ export class Blossom {
);
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]];
const rsp = await this.#req("/upload", "PUT", file, tags);
const rsp = await this.#req("upload", "PUT", "upload", file, tags);
if (rsp.ok) {
return (await rsp.json()) as BlobDescriptor;
} else {
@ -41,7 +41,7 @@ export class Blossom {
);
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]];
const rsp = await this.#req("/media", "PUT", file, tags);
const rsp = await this.#req("media", "PUT", "upload", file, tags);
if (rsp.ok) {
return (await rsp.json()) as BlobDescriptor;
} else {
@ -50,9 +50,30 @@ export class Blossom {
}
}
async list(pk: string) {
const rsp = await this.#req(`list/${pk}`, "GET", "list");
if (rsp.ok) {
return (await rsp.json()) as Array<BlobDescriptor>;
} else {
const text = await rsp.text();
throw new Error(text);
}
}
async delete(id: string) {
const tags = [["x", id]];
const rsp = await this.#req(id, "DELETE", "delete", undefined, tags);
if (!rsp.ok) {
const text = await rsp.text();
throw new Error(text);
}
}
async #req(
path: string,
method: "GET" | "POST" | "DELETE" | "PUT",
term: string,
body?: BodyInit,
tags?: Array<Array<string>>,
) {
@ -64,8 +85,8 @@ export class Blossom {
const auth = await this.publisher.generic((eb) => {
eb.kind(24_242 as EventKind)
.tag(["u", url])
.tag(["method", method])
.tag(["t", path.slice(1)])
.tag(["method", method.toLowerCase()])
.tag(["t", term])
.tag(["expiration", (now + 10).toString()]);
tags?.forEach((t) => eb.tag(t));
return eb;

View File

@ -1,6 +1,7 @@
import { NostrEvent } from "@snort/system";
import { useState } from "react";
import { FormatBytes } from "../const";
import classNames from "classnames";
interface FileInfo {
id: string;
@ -15,11 +16,13 @@ export default function FileList({
pages,
page,
onPage,
onDelete,
}: {
files: Array<File | NostrEvent>;
pages?: number;
page?: number;
onPage?: (n: number) => void;
onDelete?: (id: string) => void;
}) {
const [viewType, setViewType] = useState<"grid" | "list">("grid");
if (files.length === 0) {
@ -68,7 +71,12 @@ export default function FileList({
ret.push(
<div
onClick={() => onPage?.(x)}
className={`bg-neutral-800 hover:bg-neutral-700 min-w-8 text-center cursor-pointer font-bold ${x === start ? "rounded-l-md" : ""} ${x === n - 1 ? "rounded-r-md" : ""} ${page === x ? "bg-neutral-500" : ""}`}
className={classNames("bg-neutral-700 hover:bg-neutral-600 min-w-8 text-center cursor-pointer font-bold",
{
"rounded-l-md": x === start,
"rounded-r-md": (x + 1) === n,
"bg-neutral-400": page === x,
})}
>
{x + 1}
</div>,
@ -87,9 +95,9 @@ export default function FileList({
return (
<div
key={info.id}
className="relative rounded-md aspect-square overflow-hidden bg-neutral-800"
className="relative rounded-md aspect-square overflow-hidden bg-neutral-900"
>
<div className="absolute flex flex-col items-center justify-center w-full h-full text-wrap text-sm break-all text-center opacity-0 hover:opacity-100 hover:bg-black/60">
<div className="absolute flex flex-col items-center justify-center w-full h-full text-wrap text-sm break-all text-center opacity-0 hover:opacity-100 hover:bg-black/80">
<div>
{(info.name?.length ?? 0) === 0 ? "Untitled" : info.name}
</div>
@ -98,9 +106,17 @@ export default function FileList({
? FormatBytes(info.size, 2)
: ""}
</div>
<a href={info.url} target="_blank" className="underline">
Link
</a>
<div className="flex gap-2">
<a href={info.url} className="underline" target="_blank">
Link
</a>
<a href="#" onClick={e => {
e.preventDefault();
onDelete?.(info.id)
}} className="underline">
Delete
</a>
</div>
</div>
{renderInner(info)}
</div>
@ -118,6 +134,9 @@ export default function FileList({
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2">
Name
</th>
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2">
Type
</th>
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2">
Size
</th>
@ -131,18 +150,29 @@ export default function FileList({
const info = getInfo(a);
return (
<tr key={info.id}>
<td className="border border-neutral-500 py-1 px-2">
<td className="border border-neutral-500 py-1 px-2 break-all">
{(info.name?.length ?? 0) === 0 ? "<Untitled>" : info.name}
</td>
<td className="border border-neutral-500 py-1 px-2 break-all">
{info.type}
</td>
<td className="border border-neutral-500 py-1 px-2">
{info.size && !isNaN(info.size)
? FormatBytes(info.size, 2)
: ""}
</td>
<td className="border border-neutral-500 py-1 px-2">
<a href={info.url} className="underline" target="_blank">
Link
</a>
<div className="flex gap-2">
<a href={info.url} className="underline" target="_blank">
Link
</a>
<a href="#" onClick={e => {
e.preventDefault();
onDelete?.(info.id)
}} className="underline">
Delete
</a>
</div>
</td>
</tr>
);
@ -157,13 +187,13 @@ export default function FileList({
<div className="flex">
<div
onClick={() => setViewType("grid")}
className={`bg-neutral-800 hover:bg-neutral-600 min-w-20 text-center cursor-pointer font-bold rounded-l-md ${viewType === "grid" ? "bg-neutral-500" : ""}`}
className={`bg-neutral-700 hover:bg-neutral-600 min-w-20 text-center cursor-pointer font-bold rounded-l-md ${viewType === "grid" ? "bg-neutral-500" : ""}`}
>
Grid
</div>
<div
onClick={() => setViewType("list")}
className={`bg-neutral-800 hover:bg-neutral-600 min-w-20 text-center cursor-pointer font-bold rounded-r-md ${viewType === "list" ? "bg-neutral-500" : ""}`}
className={`bg-neutral-700 hover:bg-neutral-600 min-w-20 text-center cursor-pointer font-bold rounded-r-md ${viewType === "list" ? "bg-neutral-500" : ""}`}
>
List
</div>
@ -171,8 +201,7 @@ export default function FileList({
{viewType === "grid" ? showGrid() : showList()}
{pages !== undefined && (
<>
Page:
<div className="flex">{pageButtons(page ?? 0, pages)}</div>
<div className="flex flex-wrap">{pageButtons(page ?? 0, pages)}</div>
</>
)}
</>

View File

@ -6,13 +6,14 @@ import { Blossom } from "../upload/blossom";
import useLogin from "../hooks/login";
import usePublisher from "../hooks/publisher";
import { Nip96, Nip96FileList } from "../upload/nip96";
import { Route96 } from "../upload/admin";
import { AdminSelf, Route96 } from "../upload/admin";
import { FormatBytes } from "../const";
export default function Upload() {
const [type, setType] = useState<"blossom" | "nip96">("nip96");
const [type, setType] = useState<"blossom" | "nip96">("blossom");
const [noCompress, setNoCompress] = useState(false);
const [toUpload, setToUpload] = useState<File>();
const [self, setSelf] = useState<{ is_admin: boolean }>();
const [self, setSelf] = useState<AdminSelf>();
const [error, setError] = useState<string>();
const [results, setResults] = useState<Array<object>>([]);
const [listedFiles, setListedFiles] = useState<Nip96FileList>();
@ -88,6 +89,23 @@ export default function Upload() {
}
}
async function deleteFile(id: string) {
if (!pub) return;
try {
setError(undefined);
const uploader = new Blossom(url, pub);
await uploader.delete(id);
} catch (e) {
if (e instanceof Error) {
setError(e.message.length > 0 ? e.message : "Upload failed");
} else if (typeof e === "string") {
setError(e);
} else {
setError("List files failed");
}
}
}
useEffect(() => {
listUploads(listedPage);
}, [listedPage]);
@ -104,7 +122,7 @@ export default function Upload() {
}, [pub, self]);
return (
<div className="flex flex-col gap-2 bg-neutral-700 p-8 rounded-xl w-full">
<div className="flex flex-col gap-2 bg-neutral-800 p-8 rounded-xl w-full">
<h1 className="text-lg font-bold">
Welcome to {window.location.hostname}
</h1>
@ -136,41 +154,62 @@ export default function Upload() {
<input type="checkbox" checked={noCompress} />
</div>
<Button
onClick={async () => {
const f = await openFile();
setToUpload(f);
}}
>
Choose Files
</Button>
<FileList files={toUpload ? [toUpload] : []} />
<Button onClick={doUpload} disabled={login === undefined}>
Upload
</Button>
<Button disabled={login === undefined} onClick={() => listUploads(0)}>
{toUpload && <FileList files={toUpload ? [toUpload] : []} />}
<div className="flex gap-4">
<Button
className="flex-1"
onClick={async () => {
const f = await openFile();
setToUpload(f);
}}
>
Choose Files
</Button>
<Button
className="flex-1"
onClick={doUpload} disabled={login === undefined}>
Upload
</Button>
</div>
<hr />
{!listedFiles && <Button disabled={login === undefined} onClick={() => listUploads(0)}>
List Uploads
</Button>
</Button>}
{self && <div className="flex justify-between font-medium">
<div>Uploads: {self.file_count.toLocaleString()}</div>
<div>Total Size: {FormatBytes(self.total_size)}</div>
</div>}
{listedFiles && (
<FileList
files={listedFiles.files}
pages={listedFiles.total / listedFiles.count}
pages={Math.ceil(listedFiles.total / listedFiles.count)}
page={listedFiles.page}
onPage={(x) => setListedPage(x)}
onDelete={async (x) => {
await deleteFile(x);
await listUploads(listedPage);
}}
/>
)}
{self?.is_admin && (
<>
<hr />
<h3>Admin File List:</h3>
<Button onClick={() => listAllUploads(0)}>List All Uploads</Button>
{adminListedFiles && (
<FileList
files={adminListedFiles.files}
pages={adminListedFiles.total / adminListedFiles.count}
pages={Math.ceil(adminListedFiles.total / adminListedFiles.count)}
page={adminListedFiles.page}
onPage={(x) => setAdminListedPage(x)}
onDelete={async (x) => {
await deleteFile(x);
await listAllUploads(adminListedPage);
}
}
/>
)}
</>