feat: list files grid/table

This commit is contained in:
Kieran 2024-09-26 10:46:39 +01:00
parent 9bcdeabda8
commit b78c073066
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
7 changed files with 345 additions and 126 deletions

View File

@ -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
View 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";
}

View File

@ -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>;
}
};

View File

@ -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>
</>
)}
</>
);
}

View File

@ -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>
);
}

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",

View File

@ -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({