From c4a519afb4d4a7fdb9b4811cb1205374df796768 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Jun 2025 10:19:37 +0100 Subject: [PATCH] Add UI updates: admin reports button, quota display, and payment flow (#23) * Initial plan for issue * Implement UI updates: admin reports button, quota display, and payment flow Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> * Final implementation complete - all UI updates successfully implemented Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --- ui_src/src/components/payment.tsx | 131 ++++++++++++++++++++++++++++++ ui_src/src/upload/admin.ts | 62 +++++++++++++- ui_src/src/views/upload.tsx | 129 +++++++++++++++++++++++++++-- ui_src/tsconfig.app.tsbuildinfo | 2 +- ui_src/tsconfig.node.tsbuildinfo | 2 +- 5 files changed, 315 insertions(+), 11 deletions(-) create mode 100644 ui_src/src/components/payment.tsx diff --git a/ui_src/src/components/payment.tsx b/ui_src/src/components/payment.tsx new file mode 100644 index 0000000..8ce4282 --- /dev/null +++ b/ui_src/src/components/payment.tsx @@ -0,0 +1,131 @@ +import { useState, useEffect } from "react"; +import Button from "./button"; +import { PaymentInfo, PaymentRequest, Route96 } from "../upload/admin"; + +interface PaymentFlowProps { + route96: Route96; + onPaymentRequested?: (paymentRequest: string) => void; +} + +export default function PaymentFlow({ route96, onPaymentRequested }: PaymentFlowProps) { + const [paymentInfo, setPaymentInfo] = useState(null); + const [units, setUnits] = useState(1); + const [quantity, setQuantity] = useState(1); + const [paymentRequest, setPaymentRequest] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (paymentInfo === null) { + loadPaymentInfo(); + } + }, [paymentInfo]); + + async function loadPaymentInfo() { + try { + const info = await route96.getPaymentInfo(); + setPaymentInfo(info); + } catch (e) { + if (e instanceof Error) { + setError(e.message); + } else { + setError("Failed to load payment info"); + } + } + } + + async function requestPayment() { + if (!paymentInfo) return; + + setLoading(true); + setError(""); + + try { + const request: PaymentRequest = { units, quantity }; + const response = await route96.requestPayment(request); + setPaymentRequest(response.pr); + onPaymentRequested?.(response.pr); + } catch (e) { + if (e instanceof Error) { + setError(e.message); + } else { + setError("Failed to request payment"); + } + } finally { + setLoading(false); + } + } + + if (error && !paymentInfo) { + return
Payment not available: {error}
; + } + + if (!paymentInfo) { + return
Loading payment info...
; + } + + const totalCost = paymentInfo.cost.amount * units * quantity; + + return ( +
+

Top Up Account

+ +
+
+ + setUnits(parseFloat(e.target.value) || 0)} + className="w-full px-3 py-2 bg-neutral-800 border border-neutral-600 rounded" + /> +
+ +
+ + setQuantity(parseInt(e.target.value) || 1)} + className="w-full px-3 py-2 bg-neutral-800 border border-neutral-600 rounded" + /> +
+
+ +
+
+ Cost: {totalCost.toFixed(8)} {paymentInfo.cost.currency} per {paymentInfo.interval} +
+
+ + + + {error &&
{error}
} + + {paymentRequest && ( +
+
Lightning Invoice:
+
+ {paymentRequest} +
+
+ Copy this invoice to your Lightning wallet to complete payment +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/ui_src/src/upload/admin.ts b/ui_src/src/upload/admin.ts index 5040355..3d8557e 100644 --- a/ui_src/src/upload/admin.ts +++ b/ui_src/src/upload/admin.ts @@ -21,6 +21,24 @@ export interface Report { reviewed: boolean; } +export interface PaymentInfo { + unit: string; + interval: string; + cost: { + currency: string; + amount: number; + }; +} + +export interface PaymentRequest { + units: number; + quantity: number; +} + +export interface PaymentResponse { + pr: string; +} + export class Route96 { constructor( readonly url: string, @@ -67,6 +85,36 @@ export class Route96 { return data; } + async getPaymentInfo() { + const rsp = await this.#req("payment", "GET"); + if (rsp.ok) { + return (await rsp.json()) as PaymentInfo; + } else { + const text = await rsp.text(); + try { + const obj = JSON.parse(text) as AdminResponseBase; + throw new Error(obj.message); + } catch { + throw new Error(`Payment info failed: ${text}`); + } + } + } + + async requestPayment(request: PaymentRequest) { + const rsp = await this.#req("payment", "POST", JSON.stringify(request)); + if (rsp.ok) { + return (await rsp.json()) as PaymentResponse; + } else { + const text = await rsp.text(); + try { + const obj = JSON.parse(text) as AdminResponseBase; + throw new Error(obj.message); + } catch { + throw new Error(`Payment request failed: ${text}`); + } + } + } + async #handleResponse(rsp: Response) { if (rsp.ok) { return (await rsp.json()) as T; @@ -96,13 +144,19 @@ export class Route96 { }; const u = `${this.url}${path}`; + const headers: Record = { + accept: "application/json", + authorization: await auth(u, method), + }; + + if (body && method !== "GET") { + headers["content-type"] = "application/json"; + } + return await fetch(u, { method, body, - headers: { - accept: "application/json", - authorization: await auth(u, method), - }, + headers, }); } } diff --git a/ui_src/src/views/upload.tsx b/ui_src/src/views/upload.tsx index c874cbd..2114b73 100644 --- a/ui_src/src/views/upload.tsx +++ b/ui_src/src/views/upload.tsx @@ -1,12 +1,14 @@ import { useEffect, useState } from "react"; import Button from "../components/button"; import FileList from "./files"; +import ReportList from "./reports"; +import PaymentFlow from "../components/payment"; import { openFile } from "../upload"; import { Blossom } from "../upload/blossom"; import useLogin from "../hooks/login"; import usePublisher from "../hooks/publisher"; import { Nip96, Nip96FileList } from "../upload/nip96"; -import { AdminSelf, Route96 } from "../upload/admin"; +import { AdminSelf, Route96, Report } from "../upload/admin"; import { FormatBytes } from "../const"; export default function Upload() { @@ -18,9 +20,13 @@ export default function Upload() { const [results, setResults] = useState>([]); const [listedFiles, setListedFiles] = useState(); const [adminListedFiles, setAdminListedFiles] = useState(); + const [reports, setReports] = useState(); + const [reportPages, setReportPages] = useState(); + const [reportPage, setReportPage] = useState(0); const [listedPage, setListedPage] = useState(0); const [adminListedPage, setAdminListedPage] = useState(0); const [mimeFilter, setMimeFilter] = useState(); + const [showPaymentFlow, setShowPaymentFlow] = useState(false); const login = useLogin(); const pub = usePublisher(); @@ -93,6 +99,43 @@ export default function Upload() { } } + async function listReports(n: number) { + if (!pub) return; + try { + setError(undefined); + const route96 = new Route96(url, pub); + const result = await route96.listReports(n, 10); + setReports(result.files); + setReportPages(Math.ceil(result.total / result.count)); + } catch (e) { + if (e instanceof Error) { + setError(e.message.length > 0 ? e.message : "List reports failed"); + } else if (typeof e === "string") { + setError(e); + } else { + setError("List reports failed"); + } + } + } + + async function acknowledgeReport(reportId: number) { + if (!pub) return; + try { + setError(undefined); + const route96 = new Route96(url, pub); + await route96.acknowledgeReport(reportId); + await listReports(reportPage); // Refresh the list + } catch (e) { + if (e instanceof Error) { + setError(e.message.length > 0 ? e.message : "Acknowledge report failed"); + } else if (typeof e === "string") { + setError(e); + } else { + setError("Acknowledge report failed"); + } + } + } + async function deleteFile(id: string) { if (!pub) return; try { @@ -111,12 +154,22 @@ export default function Upload() { } useEffect(() => { - listUploads(listedPage); - }, [listedPage]); + if (pub) { + listUploads(listedPage); + } + }, [listedPage, pub]); useEffect(() => { - listAllUploads(adminListedPage); - }, [adminListedPage, mimeFilter]); + if (pub) { + listAllUploads(adminListedPage); + } + }, [adminListedPage, mimeFilter, pub]); + + useEffect(() => { + if (pub && self?.is_admin) { + listReports(reportPage); + } + }, [reportPage, pub, self?.is_admin]); useEffect(() => { if (pub && !self) { @@ -191,6 +244,55 @@ export default function Upload() { )} + {self && ( +
+

Storage Quota

+
+ {self.free_quota && ( +
+ Free Quota: {FormatBytes(self.free_quota)} +
+ )} + {self.quota && ( +
+ Paid Quota: {FormatBytes(self.quota)} +
+ )} + {self.total_available_quota && ( +
+ Total Available: {FormatBytes(self.total_available_quota)} +
+ )} + {self.total_available_quota && ( +
+ Remaining: {FormatBytes(Math.max(0, self.total_available_quota - self.total_size))} +
+ )} + {self.paid_until && ( +
+ Paid Until: {new Date(self.paid_until * 1000).toLocaleDateString()} +
+ )} +
+ +
+ )} + + {showPaymentFlow && pub && ( + { + console.log("Payment requested:", pr); + // You could add more logic here, like showing a QR code + }} + /> + )} + {listedFiles && (

Admin File List:

+