feat: list files grid/table
This commit is contained in:
parent
9bcdeabda8
commit
b78c073066
@ -3,7 +3,7 @@ import Upload from "./views/upload";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-[700px] mx-auto mt-[10dvh]">
|
||||
<div className="flex flex-col gap-4 w-[700px] mx-auto mt-4">
|
||||
<Header />
|
||||
<Upload />
|
||||
</div>
|
||||
|
45
ui_src/src/const.ts
Normal file
45
ui_src/src/const.ts
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @constant {number} - Size of 1 kiB
|
||||
*/
|
||||
export const kiB = Math.pow(1024, 1);
|
||||
/**
|
||||
* @constant {number} - Size of 1 MiB
|
||||
*/
|
||||
export const MiB = Math.pow(1024, 2);
|
||||
/**
|
||||
* @constant {number} - Size of 1 GiB
|
||||
*/
|
||||
export const GiB = Math.pow(1024, 3);
|
||||
/**
|
||||
* @constant {number} - Size of 1 TiB
|
||||
*/
|
||||
export const TiB = Math.pow(1024, 4);
|
||||
/**
|
||||
* @constant {number} - Size of 1 PiB
|
||||
*/
|
||||
export const PiB = Math.pow(1024, 5);
|
||||
/**
|
||||
* @constant {number} - Size of 1 EiB
|
||||
*/
|
||||
export const EiB = Math.pow(1024, 6);
|
||||
/**
|
||||
* @constant {number} - Size of 1 ZiB
|
||||
*/
|
||||
export const ZiB = Math.pow(1024, 7);
|
||||
/**
|
||||
* @constant {number} - Size of 1 YiB
|
||||
*/
|
||||
export const YiB = Math.pow(1024, 8);
|
||||
|
||||
export function FormatBytes(b: number, f?: number) {
|
||||
f ??= 2;
|
||||
if (b >= YiB) return (b / YiB).toFixed(f) + " YiB";
|
||||
if (b >= ZiB) return (b / ZiB).toFixed(f) + " ZiB";
|
||||
if (b >= EiB) return (b / EiB).toFixed(f) + " EiB";
|
||||
if (b >= PiB) return (b / PiB).toFixed(f) + " PiB";
|
||||
if (b >= TiB) return (b / TiB).toFixed(f) + " TiB";
|
||||
if (b >= GiB) return (b / GiB).toFixed(f) + " GiB";
|
||||
if (b >= MiB) return (b / MiB).toFixed(f) + " MiB";
|
||||
if (b >= kiB) return (b / kiB).toFixed(f) + " KiB";
|
||||
return b.toFixed(f) + " B";
|
||||
}
|
@ -22,7 +22,7 @@ export class Nip96 {
|
||||
return this.#info;
|
||||
}
|
||||
|
||||
async listFiles(page = 0, count = 50) {
|
||||
async listFiles(page = 0, count = 10) {
|
||||
const rsp = await this.#req(`?page=${page}&count=${count}`, "GET");
|
||||
const data = await this.#handleResponse<Nip96FileList>(rsp);
|
||||
return data;
|
||||
@ -37,7 +37,7 @@ export class Nip96 {
|
||||
|
||||
const rsp = await this.#req("", "POST", fd);
|
||||
const data = await this.#handleResponse<Nip96Result>(rsp);
|
||||
if(data.status !== "success") {
|
||||
if (data.status !== "success") {
|
||||
throw new Error(data.message);
|
||||
}
|
||||
return data;
|
||||
@ -93,16 +93,19 @@ export interface Nip96Info {
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
export interface Nip96Status {status: string, message?: string}
|
||||
export interface Nip96Status {
|
||||
status: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type Nip96Result = Nip96Status & {
|
||||
processing_url?: string;
|
||||
nip94_event: NostrEvent;
|
||||
}
|
||||
};
|
||||
|
||||
export type Nip96FileList = Nip96Status & {
|
||||
count: number;
|
||||
total: number;
|
||||
page: number;
|
||||
files: Array<NostrEvent>;
|
||||
}
|
||||
};
|
||||
|
@ -1,28 +1,180 @@
|
||||
export default function FileList({ files }: { files: Array<File> }) {
|
||||
import { NostrEvent } from "@snort/system";
|
||||
import { useState } from "react";
|
||||
import { FormatBytes } from "../const";
|
||||
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
url: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export default function FileList({
|
||||
files,
|
||||
pages,
|
||||
page,
|
||||
onPage,
|
||||
}: {
|
||||
files: Array<File | NostrEvent>;
|
||||
pages?: number;
|
||||
page?: number;
|
||||
onPage?: (n: number) => void;
|
||||
}) {
|
||||
const [viewType, setViewType] = useState<"grid" | "list">("grid");
|
||||
if (files.length === 0) {
|
||||
return <b>No Files</b>;
|
||||
}
|
||||
|
||||
function renderInner(f: File) {
|
||||
if (f.type.startsWith("image/")) {
|
||||
function renderInner(f: FileInfo) {
|
||||
if (f.type?.startsWith("image/")) {
|
||||
return (
|
||||
<img src={URL.createObjectURL(f)} className="object-cover bg-center" />
|
||||
<img src={f.url} className="w-full h-full object-cover object-center" />
|
||||
);
|
||||
} else if (f.type?.startsWith("video/")) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
Video
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{files.map((a) => (
|
||||
|
||||
function getInfo(f: File | NostrEvent): FileInfo {
|
||||
if ("created_at" in f) {
|
||||
return {
|
||||
id: f.tags.find((a) => a[0] === "x")![1],
|
||||
url: f.tags.find((a) => a[0] === "url")?.at(1),
|
||||
name: f.content,
|
||||
type: f.tags.find((a) => a[0] === "m")?.at(1),
|
||||
size: Number(f.tags.find((a) => a[0] === "size")?.at(1)),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
id: f.name,
|
||||
url: URL.createObjectURL(f),
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
size: f.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function pageButtons(page: number, n: number) {
|
||||
const ret = [];
|
||||
const start = 0;
|
||||
|
||||
for (let x = start; x < n; x++) {
|
||||
ret.push(
|
||||
<div
|
||||
key={a.name}
|
||||
className="relative rounded-md aspect-square overflow-hidden bg-neutral-800"
|
||||
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" : ""}`}
|
||||
>
|
||||
<div className="absolute flex flex-col items-center justify-center w-full h-full bg-black/50 text-wrap text-sm break-all text-center">
|
||||
{a.name}
|
||||
</div>
|
||||
{renderInner(a)}
|
||||
{x + 1}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function showGrid() {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{files.map((a) => {
|
||||
const info = getInfo(a);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={info.id}
|
||||
className="relative rounded-md aspect-square overflow-hidden bg-neutral-800"
|
||||
>
|
||||
<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>
|
||||
{(info.name?.length ?? 0) === 0 ? "Untitled" : info.name}
|
||||
</div>
|
||||
<div>
|
||||
{info.size && !isNaN(info.size)
|
||||
? FormatBytes(info.size, 2)
|
||||
: ""}
|
||||
</div>
|
||||
<a href={info.url} target="_blank" className="underline">
|
||||
Link
|
||||
</a>
|
||||
</div>
|
||||
{renderInner(info)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function showList() {
|
||||
return (
|
||||
<table className="table-auto text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<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">
|
||||
Size
|
||||
</th>
|
||||
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((a) => {
|
||||
const info = getInfo(a);
|
||||
return (
|
||||
<tr key={info.id}>
|
||||
<td className="border border-neutral-500 py-1 px-2">
|
||||
{(info.name?.length ?? 0) === 0 ? "<Untitled>" : info.name}
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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" : ""}`}
|
||||
>
|
||||
Grid
|
||||
</div>
|
||||
))}
|
||||
</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" : ""}`}
|
||||
>
|
||||
List
|
||||
</div>
|
||||
</div>
|
||||
{viewType === "grid" ? showGrid() : showList()}
|
||||
{pages !== undefined && (
|
||||
<>
|
||||
Page:
|
||||
<div className="flex">{pageButtons(page ?? 0, pages)}</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,119 +1,138 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Button from "../components/button";
|
||||
import FileList from "./files";
|
||||
import { openFile } from "../upload";
|
||||
import { Blossom } from "../upload/blossom";
|
||||
import useLogin from "../hooks/login";
|
||||
import usePublisher from "../hooks/publisher";
|
||||
import { Nip96 } from "../upload/nip96";
|
||||
import { Nip96, Nip96FileList } from "../upload/nip96";
|
||||
|
||||
export default function Upload() {
|
||||
const [type, setType] = useState<"blossom" | "nip96">("nip96");
|
||||
const [noCompress, setNoCompress] = useState(false);
|
||||
const [toUpload, setToUpload] = useState<File>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [results, setResults] = useState<Array<object>>([]);
|
||||
const login = useLogin();
|
||||
const pub = usePublisher();
|
||||
const [type, setType] = useState<"blossom" | "nip96">("nip96");
|
||||
const [noCompress, setNoCompress] = useState(false);
|
||||
const [toUpload, setToUpload] = useState<File>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [results, setResults] = useState<Array<object>>([]);
|
||||
const [listedFiles, setListedFiles] = useState<Nip96FileList>();
|
||||
const [listedPage, setListedPage] = useState(0);
|
||||
|
||||
async function doUpload() {
|
||||
if (!pub) return;
|
||||
if (!toUpload) return;
|
||||
try {
|
||||
setError(undefined);
|
||||
const url = `${location.protocol}//${location.host}`;
|
||||
if (type === "blossom") {
|
||||
const uploader = new Blossom(url, pub);
|
||||
const result = await uploader.upload(toUpload);
|
||||
setResults(s => [...s, result]);
|
||||
}
|
||||
if (type === "nip96") {
|
||||
const uploader = new Nip96(url, pub);
|
||||
await uploader.loadInfo();
|
||||
const result = await uploader.upload(toUpload);
|
||||
setResults(s => [...s, result]);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message.length > 0 ? e.message : "Upload failed");
|
||||
} else if (typeof e === "string") {
|
||||
setError(e);
|
||||
} else {
|
||||
setError("Upload failed");
|
||||
}
|
||||
}
|
||||
const login = useLogin();
|
||||
const pub = usePublisher();
|
||||
|
||||
const url = `${location.protocol}//${location.host}`;
|
||||
//const url = "https://files.v0l.io";
|
||||
async function doUpload() {
|
||||
if (!pub) return;
|
||||
if (!toUpload) return;
|
||||
try {
|
||||
setError(undefined);
|
||||
if (type === "blossom") {
|
||||
const uploader = new Blossom(url, pub);
|
||||
const result = await uploader.upload(toUpload);
|
||||
setResults((s) => [...s, result]);
|
||||
}
|
||||
if (type === "nip96") {
|
||||
const uploader = new Nip96(url, pub);
|
||||
await uploader.loadInfo();
|
||||
const result = await uploader.upload(toUpload);
|
||||
setResults((s) => [...s, result]);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message.length > 0 ? e.message : "Upload failed");
|
||||
} else if (typeof e === "string") {
|
||||
setError(e);
|
||||
} else {
|
||||
setError("Upload failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listUploads() {
|
||||
if (!pub) return;
|
||||
try {
|
||||
setError(undefined);
|
||||
const url = `${location.protocol}//${location.host}`;
|
||||
const uploader = new Nip96(url, pub);
|
||||
await uploader.loadInfo();
|
||||
const result = await uploader.listFiles();
|
||||
setResults(s => [...s, result]);
|
||||
} 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");
|
||||
}
|
||||
}
|
||||
async function listUploads(n: number) {
|
||||
if (!pub) return;
|
||||
try {
|
||||
setError(undefined);
|
||||
const uploader = new Nip96(url, pub);
|
||||
await uploader.loadInfo();
|
||||
const result = await uploader.listFiles(n, 12);
|
||||
setListedFiles(result);
|
||||
} 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 bg-neutral-700 p-8 rounded-xl w-full">
|
||||
<h1 className="text-lg font-bold">
|
||||
Welcome to {window.location.hostname}
|
||||
</h1>
|
||||
<div className="text-neutral-400 uppercase text-xs font-medium">
|
||||
Upload Method
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div
|
||||
className="flex gap-2 cursor-pointer"
|
||||
onClick={() => setType("blossom")}
|
||||
>
|
||||
Blossom
|
||||
<input type="radio" checked={type === "blossom"} />
|
||||
</div>
|
||||
<div
|
||||
className="flex gap-2 cursor-pointer"
|
||||
onClick={() => setType("nip96")}
|
||||
>
|
||||
NIP-96
|
||||
<input type="radio" checked={type === "nip96"} />
|
||||
</div>
|
||||
</div>
|
||||
useEffect(() => {
|
||||
listUploads(listedPage);
|
||||
}, [listedPage]);
|
||||
|
||||
{type === "nip96" && (
|
||||
<div
|
||||
className="flex gap-2 cursor-pointer"
|
||||
onClick={() => setNoCompress((s) => !s)}
|
||||
>
|
||||
Disable Compression
|
||||
<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}>List Uploads</Button>
|
||||
{error && <b className="text-red-500">{error}</b>}
|
||||
<pre className="text-xs font-monospace overflow-wrap">{JSON.stringify(results, undefined, 2)}</pre>
|
||||
return (
|
||||
<div className="flex flex-col gap-2 bg-neutral-700 p-8 rounded-xl w-full">
|
||||
<h1 className="text-lg font-bold">
|
||||
Welcome to {window.location.hostname}
|
||||
</h1>
|
||||
<div className="text-neutral-400 uppercase text-xs font-medium">
|
||||
Upload Method
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div
|
||||
className="flex gap-2 cursor-pointer"
|
||||
onClick={() => setType("blossom")}
|
||||
>
|
||||
Blossom
|
||||
<input type="radio" checked={type === "blossom"} />
|
||||
</div>
|
||||
);
|
||||
<div
|
||||
className="flex gap-2 cursor-pointer"
|
||||
onClick={() => setType("nip96")}
|
||||
>
|
||||
NIP-96
|
||||
<input type="radio" checked={type === "nip96"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{type === "nip96" && (
|
||||
<div
|
||||
className="flex gap-2 cursor-pointer"
|
||||
onClick={() => setNoCompress((s) => !s)}
|
||||
>
|
||||
Disable Compression
|
||||
<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)}>
|
||||
List Uploads
|
||||
</Button>
|
||||
{listedFiles && (
|
||||
<FileList
|
||||
files={listedFiles.files}
|
||||
pages={listedFiles.total / listedFiles.count}
|
||||
page={listedFiles.page}
|
||||
onPage={(x) => setListedPage(x)}
|
||||
/>
|
||||
)}
|
||||
{error && <b className="text-red-500">{error}</b>}
|
||||
<pre className="text-xs font-monospace overflow-wrap">
|
||||
{JSON.stringify(results, undefined, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { viteSingleFile } from "vite-plugin-singlefile"
|
||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
|
Loading…
x
Reference in New Issue
Block a user