feat: admin delete
feat: total usage stats
This commit is contained in:
parent
b30b90cb41
commit
961a6910ac
@ -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"
|
||||||
|
27
src/db.rs
27
src/db.rs
@ -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)
|
||||||
|
@ -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,
|
||||||
|
@ -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 {
|
||||||
|
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -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)}
|
||||||
|
@ -4,5 +4,9 @@
|
|||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
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 { 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);
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -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"}
|
@ -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"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user