feat: admin delete

feat: total usage stats
This commit is contained in:
kieran 2024-12-16 12:52:27 +00:00
parent b30b90cb41
commit 961a6910ac
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
13 changed files with 228 additions and 63 deletions

View File

@ -15,7 +15,7 @@ path = "src/bin/main.rs"
name = "route96" name = "route96"
[features] [features]
default = ["nip96", "blossom", "analytics", "ranges"] default = ["nip96", "blossom", "analytics", "ranges", "react-ui"]
media-compression = ["dep:ffmpeg-rs-raw", "dep:libc"] media-compression = ["dep:ffmpeg-rs-raw", "dep:libc"]
labels = ["nip96", "dep:candle-core", "dep:candle-nn", "dep:candle-transformers"] labels = ["nip96", "dep:candle-core", "dep:candle-nn", "dep:candle-transformers"]
nip96 = ["media-compression"] nip96 = ["media-compression"]
@ -25,6 +25,7 @@ torrent-v2 = []
analytics = [] analytics = []
void-cat-redirects = ["dep:sqlx-postgres"] void-cat-redirects = ["dep:sqlx-postgres"]
ranges = ["dep:http-range-header"] ranges = ["dep:http-range-header"]
react-ui = []
[dependencies] [dependencies]
log = "0.4.21" log = "0.4.21"

View File

@ -51,6 +51,12 @@ impl FileLabel {
} }
} }
#[derive(Clone, FromRow, Serialize)]
pub struct UserStats {
pub file_count: u64,
pub total_size: u64,
}
#[derive(Clone)] #[derive(Clone)]
pub struct Database { pub struct Database {
pub(crate) pool: sqlx::pool::Pool<sqlx::mysql::MySql>, pub(crate) pool: sqlx::pool::Pool<sqlx::mysql::MySql>,
@ -88,6 +94,19 @@ impl Database {
.await .await
} }
pub async fn get_user_stats(&self, id: u64) -> Result<UserStats, Error> {
sqlx::query_as(
"select cast(count(user_uploads.file) as unsigned integer) as file_count, \
cast(sum(uploads.size) as unsigned integer) as total_size \
from user_uploads,uploads \
where user_uploads.user_id = ? \
and user_uploads.file = uploads.id",
)
.bind(id)
.fetch_one(&self.pool)
.await
}
pub async fn get_user_id(&self, pubkey: &Vec<u8>) -> Result<u64, Error> { pub async fn get_user_id(&self, pubkey: &Vec<u8>) -> Result<u64, Error> {
sqlx::query("select id from users where pubkey = ?") sqlx::query("select id from users where pubkey = ?")
.bind(pubkey) .bind(pubkey)
@ -167,6 +186,14 @@ impl Database {
Ok(()) Ok(())
} }
pub async fn delete_all_file_owner(&self, file: &Vec<u8>) -> Result<(), Error> {
sqlx::query("delete from user_uploads where file = ?")
.bind(file)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn delete_file(&self, file: &Vec<u8>) -> Result<(), Error> { pub async fn delete_file(&self, file: &Vec<u8>) -> Result<(), Error> {
sqlx::query("delete from uploads where id = ?") sqlx::query("delete from uploads where id = ?")
.bind(file) .bind(file)

View File

@ -1,5 +1,5 @@
use crate::auth::nip98::Nip98Auth; use crate::auth::nip98::Nip98Auth;
use crate::db::{Database, FileUpload, User}; use crate::db::{Database, FileUpload};
use crate::routes::{Nip94Event, PagedResult}; use crate::routes::{Nip94Event, PagedResult};
use crate::settings::Settings; use crate::settings::Settings;
use rocket::serde::json::Json; use rocket::serde::json::Json;
@ -48,11 +48,30 @@ impl<T> AdminResponse<T> {
} }
} }
#[derive(Serialize)]
pub struct SelfUser {
pub is_admin: bool,
pub file_count: u64,
pub total_size: u64,
}
#[rocket::get("/self")] #[rocket::get("/self")]
async fn admin_get_self(auth: Nip98Auth, db: &State<Database>) -> AdminResponse<User> { async fn admin_get_self(auth: Nip98Auth, db: &State<Database>) -> AdminResponse<SelfUser> {
let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); let pubkey_vec = auth.event.pubkey.to_bytes().to_vec();
match db.get_user(&pubkey_vec).await { match db.get_user(&pubkey_vec).await {
Ok(user) => AdminResponse::success(user), Ok(user) => {
let s = match db.get_user_stats(user.id).await {
Ok(r) => r,
Err(e) => {
return AdminResponse::error(&format!("Failed to load user stats: {}", e))
}
};
AdminResponse::success(SelfUser {
is_admin: user.is_admin,
file_count: s.file_count,
total_size: s.total_size,
})
}
Err(_) => AdminResponse::error("User not found"), Err(_) => AdminResponse::error("User not found"),
} }
} }
@ -66,7 +85,7 @@ async fn admin_list_files(
settings: &State<Settings>, settings: &State<Settings>,
) -> AdminResponse<PagedResult<Nip94Event>> { ) -> AdminResponse<PagedResult<Nip94Event>> {
let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); let pubkey_vec = auth.event.pubkey.to_bytes().to_vec();
let server_count = count.min(5_000).max(1); let server_count = count.clamp(1, 5_000);
let user = match db.get_user(&pubkey_vec).await { let user = match db.get_user(&pubkey_vec).await {
Ok(user) => user, Ok(user) => user,

View File

@ -253,22 +253,34 @@ async fn delete_file(
} }
if let Ok(Some(_info)) = db.get_file(&id).await { if let Ok(Some(_info)) = db.get_file(&id).await {
let pubkey_vec = auth.pubkey.to_bytes().to_vec(); let pubkey_vec = auth.pubkey.to_bytes().to_vec();
let auth_user = db.get_user(&pubkey_vec).await?;
let owners = db.get_file_owners(&id).await?; let owners = db.get_file_owners(&id).await?;
if auth_user.is_admin {
let this_owner = match owners.iter().find(|o| o.pubkey.eq(&pubkey_vec)) { if let Err(e) = db.delete_all_file_owner(&id).await {
Some(o) => o, return Err(Error::msg(format!("Failed to delete (db): {}", e)));
None => return Err(Error::msg("You dont own this file, you cannot delete it")), }
};
if let Err(e) = db.delete_file_owner(&id, this_owner.id).await {
return Err(Error::msg(format!("Failed to delete (db): {}", e)));
}
// only 1 owner was left, delete file completely
if owners.len() == 1 {
if let Err(e) = db.delete_file(&id).await { if let Err(e) = db.delete_file(&id).await {
return Err(Error::msg(format!("Failed to delete (fs): {}", e))); return Err(Error::msg(format!("Failed to delete (fs): {}", e)));
} }
if let Err(e) = tokio::fs::remove_file(fs.get(&id)).await { if let Err(e) = tokio::fs::remove_file(fs.get(&id)).await {
return Err(Error::msg(format!("Failed to delete (fs): {}", e))); warn!("Failed to delete (fs): {}", e);
}
} else {
let this_owner = match owners.iter().find(|o| o.pubkey.eq(&pubkey_vec)) {
Some(o) => o,
None => return Err(Error::msg("You dont own this file, you cannot delete it")),
};
if let Err(e) = db.delete_file_owner(&id, this_owner.id).await {
return Err(Error::msg(format!("Failed to delete (db): {}", e)));
}
// only 1 owner was left, delete file completely
if owners.len() == 1 {
if let Err(e) = db.delete_file(&id).await {
return Err(Error::msg(format!("Failed to delete (fs): {}", e)));
}
if let Err(e) = tokio::fs::remove_file(fs.get(&id)).await {
warn!("Failed to delete (fs): {}", e);
}
} }
} }
Ok(()) Ok(())
@ -279,10 +291,12 @@ async fn delete_file(
#[rocket::get("/")] #[rocket::get("/")]
pub async fn root() -> Result<NamedFile, Status> { pub async fn root() -> Result<NamedFile, Status> {
#[cfg(debug_assertions)] #[cfg(all(debug_assertions, feature = "react-ui"))]
let index = "./index.html"; let index = "./ui_src/dist/index.html";
#[cfg(not(debug_assertions))] #[cfg(all(not(debug_assertions), feature = "react-ui"))]
let index = "./ui/index.html"; let index = "./ui/index.html";
#[cfg(not(feature = "react-ui"))]
let index = "./index.html";
if let Ok(f) = NamedFile::open(index).await { if let Ok(f) = NamedFile::open(index).await {
Ok(f) Ok(f)
} else { } else {

View File

@ -14,6 +14,7 @@
"@snort/shared": "^1.0.17", "@snort/shared": "^1.0.17",
"@snort/system": "^1.5.1", "@snort/system": "^1.5.1",
"@snort/system-react": "^1.5.1", "@snort/system-react": "^1.5.1",
"classnames": "^2.5.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },

View File

@ -22,7 +22,7 @@ export default function Button({
} }
return ( return (
<button <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} onClick={doClick}
{...props} {...props}
disabled={loading || (props.disabled ?? false)} disabled={loading || (props.disabled ?? false)}

View File

@ -4,5 +4,9 @@
html, html,
body { 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 { throwIfOffline } from "@snort/shared";
import { EventKind, EventPublisher, NostrEvent } from "@snort/system"; import { EventKind, EventPublisher, NostrEvent } from "@snort/system";
export interface AdminSelf { is_admin: boolean, file_count: number, total_size: number }
export class Route96 { export class Route96 {
constructor( constructor(
readonly url: string, readonly url: string,
@ -11,15 +13,15 @@ export class Route96 {
} }
async getSelf() { async getSelf() {
const rsp = await this.#req("/admin/self", "GET"); const rsp = await this.#req("admin/self", "GET");
const data = const data =
await this.#handleResponse<AdminResponse<{ is_admin: boolean }>>(rsp); await this.#handleResponse<AdminResponse<AdminSelf>>(rsp);
return data; return data;
} }
async listFiles(page = 0, count = 10) { async listFiles(page = 0, count = 10) {
const rsp = await this.#req( const rsp = await this.#req(
`/admin/files?page=${page}&count=${count}`, `admin/files?page=${page}&count=${count}`,
"GET", "GET",
); );
const data = await this.#handleResponse<AdminResponseFileList>(rsp); 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 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) { if (rsp.ok) {
return (await rsp.json()) as BlobDescriptor; return (await rsp.json()) as BlobDescriptor;
} else { } else {
@ -41,7 +41,7 @@ export class Blossom {
); );
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]]; 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) { if (rsp.ok) {
return (await rsp.json()) as BlobDescriptor; return (await rsp.json()) as BlobDescriptor;
} else { } 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( async #req(
path: string, path: string,
method: "GET" | "POST" | "DELETE" | "PUT", method: "GET" | "POST" | "DELETE" | "PUT",
term: string,
body?: BodyInit, body?: BodyInit,
tags?: Array<Array<string>>, tags?: Array<Array<string>>,
) { ) {
@ -64,8 +85,8 @@ export class Blossom {
const auth = await this.publisher.generic((eb) => { const auth = await this.publisher.generic((eb) => {
eb.kind(24_242 as EventKind) eb.kind(24_242 as EventKind)
.tag(["u", url]) .tag(["u", url])
.tag(["method", method]) .tag(["method", method.toLowerCase()])
.tag(["t", path.slice(1)]) .tag(["t", term])
.tag(["expiration", (now + 10).toString()]); .tag(["expiration", (now + 10).toString()]);
tags?.forEach((t) => eb.tag(t)); tags?.forEach((t) => eb.tag(t));
return eb; return eb;

View File

@ -1,6 +1,7 @@
import { NostrEvent } from "@snort/system"; import { NostrEvent } from "@snort/system";
import { useState } from "react"; import { useState } from "react";
import { FormatBytes } from "../const"; import { FormatBytes } from "../const";
import classNames from "classnames";
interface FileInfo { interface FileInfo {
id: string; id: string;
@ -15,11 +16,13 @@ export default function FileList({
pages, pages,
page, page,
onPage, onPage,
onDelete,
}: { }: {
files: Array<File | NostrEvent>; files: Array<File | NostrEvent>;
pages?: number; pages?: number;
page?: number; page?: number;
onPage?: (n: number) => void; onPage?: (n: number) => void;
onDelete?: (id: string) => void;
}) { }) {
const [viewType, setViewType] = useState<"grid" | "list">("grid"); const [viewType, setViewType] = useState<"grid" | "list">("grid");
if (files.length === 0) { if (files.length === 0) {
@ -68,7 +71,12 @@ export default function FileList({
ret.push( ret.push(
<div <div
onClick={() => onPage?.(x)} 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} {x + 1}
</div>, </div>,
@ -87,9 +95,9 @@ export default function FileList({
return ( return (
<div <div
key={info.id} 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> <div>
{(info.name?.length ?? 0) === 0 ? "Untitled" : info.name} {(info.name?.length ?? 0) === 0 ? "Untitled" : info.name}
</div> </div>
@ -98,9 +106,17 @@ export default function FileList({
? FormatBytes(info.size, 2) ? FormatBytes(info.size, 2)
: ""} : ""}
</div> </div>
<a href={info.url} target="_blank" className="underline"> <div className="flex gap-2">
Link <a href={info.url} className="underline" target="_blank">
</a> Link
</a>
<a href="#" onClick={e => {
e.preventDefault();
onDelete?.(info.id)
}} className="underline">
Delete
</a>
</div>
</div> </div>
{renderInner(info)} {renderInner(info)}
</div> </div>
@ -118,6 +134,9 @@ export default function FileList({
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2"> <th className="border border-neutral-400 bg-neutral-500 py-1 px-2">
Name Name
</th> </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"> <th className="border border-neutral-400 bg-neutral-500 py-1 px-2">
Size Size
</th> </th>
@ -131,18 +150,29 @@ export default function FileList({
const info = getInfo(a); const info = getInfo(a);
return ( return (
<tr key={info.id}> <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} {(info.name?.length ?? 0) === 0 ? "<Untitled>" : info.name}
</td> </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"> <td className="border border-neutral-500 py-1 px-2">
{info.size && !isNaN(info.size) {info.size && !isNaN(info.size)
? FormatBytes(info.size, 2) ? FormatBytes(info.size, 2)
: ""} : ""}
</td> </td>
<td className="border border-neutral-500 py-1 px-2"> <td className="border border-neutral-500 py-1 px-2">
<a href={info.url} className="underline" target="_blank"> <div className="flex gap-2">
Link <a href={info.url} className="underline" target="_blank">
</a> Link
</a>
<a href="#" onClick={e => {
e.preventDefault();
onDelete?.(info.id)
}} className="underline">
Delete
</a>
</div>
</td> </td>
</tr> </tr>
); );
@ -157,13 +187,13 @@ export default function FileList({
<div className="flex"> <div className="flex">
<div <div
onClick={() => setViewType("grid")} 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 Grid
</div> </div>
<div <div
onClick={() => setViewType("list")} 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 List
</div> </div>
@ -171,8 +201,7 @@ export default function FileList({
{viewType === "grid" ? showGrid() : showList()} {viewType === "grid" ? showGrid() : showList()}
{pages !== undefined && ( {pages !== undefined && (
<> <>
Page: <div className="flex flex-wrap">{pageButtons(page ?? 0, pages)}</div>
<div className="flex">{pageButtons(page ?? 0, pages)}</div>
</> </>
)} )}
</> </>

View File

@ -6,13 +6,14 @@ import { Blossom } from "../upload/blossom";
import useLogin from "../hooks/login"; import useLogin from "../hooks/login";
import usePublisher from "../hooks/publisher"; import usePublisher from "../hooks/publisher";
import { Nip96, Nip96FileList } from "../upload/nip96"; 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() { export default function Upload() {
const [type, setType] = useState<"blossom" | "nip96">("nip96"); const [type, setType] = useState<"blossom" | "nip96">("blossom");
const [noCompress, setNoCompress] = useState(false); const [noCompress, setNoCompress] = useState(false);
const [toUpload, setToUpload] = useState<File>(); const [toUpload, setToUpload] = useState<File>();
const [self, setSelf] = useState<{ is_admin: boolean }>(); const [self, setSelf] = useState<AdminSelf>();
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [results, setResults] = useState<Array<object>>([]); const [results, setResults] = useState<Array<object>>([]);
const [listedFiles, setListedFiles] = useState<Nip96FileList>(); 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(() => { useEffect(() => {
listUploads(listedPage); listUploads(listedPage);
}, [listedPage]); }, [listedPage]);
@ -104,7 +122,7 @@ export default function Upload() {
}, [pub, self]); }, [pub, self]);
return ( 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"> <h1 className="text-lg font-bold">
Welcome to {window.location.hostname} Welcome to {window.location.hostname}
</h1> </h1>
@ -136,41 +154,62 @@ export default function Upload() {
<input type="checkbox" checked={noCompress} /> <input type="checkbox" checked={noCompress} />
</div> </div>
<Button {toUpload && <FileList files={toUpload ? [toUpload] : []} />}
onClick={async () => { <div className="flex gap-4">
const f = await openFile(); <Button
setToUpload(f); className="flex-1"
}} onClick={async () => {
> const f = await openFile();
Choose Files setToUpload(f);
</Button> }}
<FileList files={toUpload ? [toUpload] : []} /> >
<Button onClick={doUpload} disabled={login === undefined}> Choose Files
Upload </Button>
</Button> <Button
<Button disabled={login === undefined} onClick={() => listUploads(0)}> className="flex-1"
onClick={doUpload} disabled={login === undefined}>
Upload
</Button>
</div>
<hr />
{!listedFiles && <Button disabled={login === undefined} onClick={() => listUploads(0)}>
List Uploads 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 && ( {listedFiles && (
<FileList <FileList
files={listedFiles.files} files={listedFiles.files}
pages={listedFiles.total / listedFiles.count} pages={Math.ceil(listedFiles.total / listedFiles.count)}
page={listedFiles.page} page={listedFiles.page}
onPage={(x) => setListedPage(x)} onPage={(x) => setListedPage(x)}
onDelete={async (x) => {
await deleteFile(x);
await listUploads(listedPage);
}}
/> />
)} )}
{self?.is_admin && ( {self?.is_admin && (
<> <>
<hr />
<h3>Admin File List:</h3> <h3>Admin File List:</h3>
<Button onClick={() => listAllUploads(0)}>List All Uploads</Button> <Button onClick={() => listAllUploads(0)}>List All Uploads</Button>
{adminListedFiles && ( {adminListedFiles && (
<FileList <FileList
files={adminListedFiles.files} files={adminListedFiles.files}
pages={adminListedFiles.total / adminListedFiles.count} pages={Math.ceil(adminListedFiles.total / adminListedFiles.count)}
page={adminListedFiles.page} page={adminListedFiles.page}
onPage={(x) => setAdminListedPage(x)} onPage={(x) => setAdminListedPage(x)}
onDelete={async (x) => {
await deleteFile(x);
await listAllUploads(adminListedPage);
}
}
/> />
)} )}
</> </>

View File

@ -1 +1 @@
{"root":["./src/App.tsx","./src/const.ts","./src/login.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/button.tsx","./src/components/profile.tsx","./src/hooks/login.ts","./src/hooks/publisher.ts","./src/upload/blossom.ts","./src/upload/index.ts","./src/upload/nip96.ts","./src/views/files.tsx","./src/views/header.tsx","./src/views/upload.tsx"],"version":"5.6.2"} {"root":["./src/App.tsx","./src/const.ts","./src/login.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/button.tsx","./src/components/profile.tsx","./src/hooks/login.ts","./src/hooks/publisher.ts","./src/upload/admin.ts","./src/upload/blossom.ts","./src/upload/index.ts","./src/upload/nip96.ts","./src/views/files.tsx","./src/views/header.tsx","./src/views/upload.tsx"],"version":"5.6.2"}

View File

@ -1455,6 +1455,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"classnames@npm:^2.5.1":
version: 2.5.1
resolution: "classnames@npm:2.5.1"
checksum: 10c0/afff4f77e62cea2d79c39962980bf316bacb0d7c49e13a21adaadb9221e1c6b9d3cdb829d8bb1b23c406f4e740507f37e1dcf506f7e3b7113d17c5bab787aa69
languageName: node
linkType: hard
"clean-stack@npm:^2.0.0": "clean-stack@npm:^2.0.0":
version: 2.2.0 version: 2.2.0
resolution: "clean-stack@npm:2.2.0" resolution: "clean-stack@npm:2.2.0"
@ -3721,6 +3728,7 @@ __metadata:
"@types/react-dom": "npm:^18.3.0" "@types/react-dom": "npm:^18.3.0"
"@vitejs/plugin-react": "npm:^4.3.1" "@vitejs/plugin-react": "npm:^4.3.1"
autoprefixer: "npm:^10.4.20" autoprefixer: "npm:^10.4.20"
classnames: "npm:^2.5.1"
eslint: "npm:^9.9.0" eslint: "npm:^9.9.0"
eslint-plugin-react-hooks: "npm:^5.1.0-rc.0" eslint-plugin-react-hooks: "npm:^5.1.0-rc.0"
eslint-plugin-react-refresh: "npm:^0.4.9" eslint-plugin-react-refresh: "npm:^0.4.9"