mirror of
https://github.com/v0l/route96.git
synced 2025-06-20 07:10:30 +00:00
feat: admin delete
feat: total usage stats
This commit is contained in:
@ -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)}
|
||||
|
@ -4,5 +4,9 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-neutral-900 text-white;
|
||||
@apply bg-black text-white;
|
||||
}
|
||||
|
||||
hr {
|
||||
@apply border-neutral-500
|
||||
}
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
Reference in New Issue
Block a user