Add UI updates: admin reports button, quota display, and payment flow (#23)
Some checks failed
continuous-integration/drone/push Build is failing

* 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>
This commit is contained in:
Copilot
2025-06-11 10:19:37 +01:00
committed by GitHub
parent fe263e9a46
commit c4a519afb4
5 changed files with 315 additions and 11 deletions

View File

@ -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<PaymentInfo | null>(null);
const [units, setUnits] = useState<number>(1);
const [quantity, setQuantity] = useState<number>(1);
const [paymentRequest, setPaymentRequest] = useState<string>("");
const [error, setError] = useState<string>("");
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 <div className="text-red-500">Payment not available: {error}</div>;
}
if (!paymentInfo) {
return <div>Loading payment info...</div>;
}
const totalCost = paymentInfo.cost.amount * units * quantity;
return (
<div className="bg-neutral-700 p-4 rounded-lg">
<h3 className="text-lg font-bold mb-4">Top Up Account</h3>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium mb-1">
Units ({paymentInfo.unit})
</label>
<input
type="number"
min="0.1"
step="0.1"
value={units}
onChange={(e) => setUnits(parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 bg-neutral-800 border border-neutral-600 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Quantity
</label>
<input
type="number"
min="1"
value={quantity}
onChange={(e) => setQuantity(parseInt(e.target.value) || 1)}
className="w-full px-3 py-2 bg-neutral-800 border border-neutral-600 rounded"
/>
</div>
</div>
<div className="mb-4">
<div className="text-sm text-neutral-300">
Cost: {totalCost.toFixed(8)} {paymentInfo.cost.currency} per {paymentInfo.interval}
</div>
</div>
<Button
onClick={requestPayment}
disabled={loading || units <= 0 || quantity <= 0}
className="w-full mb-4"
>
{loading ? "Processing..." : "Generate Payment Request"}
</Button>
{error && <div className="text-red-500 text-sm mb-4">{error}</div>}
{paymentRequest && (
<div className="bg-neutral-800 p-4 rounded">
<div className="text-sm font-medium mb-2">Lightning Invoice:</div>
<div className="font-mono text-xs break-all bg-neutral-900 p-2 rounded">
{paymentRequest}
</div>
<div className="text-xs text-neutral-400 mt-2">
Copy this invoice to your Lightning wallet to complete payment
</div>
</div>
)}
</div>
);
}

View File

@ -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<T extends AdminResponseBase>(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<string, string> = {
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,
});
}
}

View File

@ -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<Array<object>>([]);
const [listedFiles, setListedFiles] = useState<Nip96FileList>();
const [adminListedFiles, setAdminListedFiles] = useState<Nip96FileList>();
const [reports, setReports] = useState<Report[]>();
const [reportPages, setReportPages] = useState<number>();
const [reportPage, setReportPage] = useState(0);
const [listedPage, setListedPage] = useState(0);
const [adminListedPage, setAdminListedPage] = useState(0);
const [mimeFilter, setMimeFilter] = useState<string>();
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() {
</div>
)}
{self && (
<div className="bg-neutral-700 p-4 rounded-lg">
<h3 className="text-lg font-bold mb-2">Storage Quota</h3>
<div className="space-y-2">
{self.free_quota && (
<div className="text-sm">
Free Quota: {FormatBytes(self.free_quota)}
</div>
)}
{self.quota && (
<div className="text-sm">
Paid Quota: {FormatBytes(self.quota)}
</div>
)}
{self.total_available_quota && (
<div className="text-sm font-medium">
Total Available: {FormatBytes(self.total_available_quota)}
</div>
)}
{self.total_available_quota && (
<div className="text-sm">
Remaining: {FormatBytes(Math.max(0, self.total_available_quota - self.total_size))}
</div>
)}
{self.paid_until && (
<div className="text-sm text-neutral-300">
Paid Until: {new Date(self.paid_until * 1000).toLocaleDateString()}
</div>
)}
</div>
<Button
onClick={() => setShowPaymentFlow(!showPaymentFlow)}
className="mt-3 w-full"
>
{showPaymentFlow ? "Hide" : "Show"} Top Up Options
</Button>
</div>
)}
{showPaymentFlow && pub && (
<PaymentFlow
route96={new Route96(url, pub)}
onPaymentRequested={(pr) => {
console.log("Payment requested:", pr);
// You could add more logic here, like showing a QR code
}}
/>
)}
{listedFiles && (
<FileList
files={listedFiles.files}
@ -209,6 +311,7 @@ export default function Upload() {
<hr />
<h3>Admin File List:</h3>
<Button onClick={() => listAllUploads(0)}>List All Uploads</Button>
<Button onClick={() => listReports(0)}>List Reports</Button>
<div>
<select value={mimeFilter} onChange={e => setMimeFilter(e.target.value)}>
<option value={""}>All</option>
@ -233,6 +336,22 @@ export default function Upload() {
}}
/>
)}
{reports && (
<>
<h3>Reports:</h3>
<ReportList
reports={reports}
pages={reportPages}
page={reportPage}
onPage={(x) => setReportPage(x)}
onAcknowledge={acknowledgeReport}
onDeleteFile={async (fileId) => {
await deleteFile(fileId);
await listReports(reportPage); // Refresh reports after deleting file
}}
/>
</>
)}
</>
)}
{error && <b className="text-red-500">{error}</b>}

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/admin.ts","./src/upload/blossom.ts","./src/upload/index.ts","./src/upload/nip96.ts","./src/views/files.tsx","./src/views/header.tsx","./src/views/reports.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/payment.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/reports.tsx","./src/views/upload.tsx"],"errors":true,"version":"5.8.3"}

View File

@ -1 +1 @@
{"root":["./vite.config.ts"],"version":"5.6.2"}
{"root":["./vite.config.ts"],"errors":true,"version":"5.8.3"}