mirror of
https://github.com/v0l/route96.git
synced 2025-06-13 23:32:02 +00:00
Add UI updates: admin reports button, quota display, and payment flow (#23)
Some checks failed
continuous-integration/drone/push Build is failing
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:
131
ui_src/src/components/payment.tsx
Normal file
131
ui_src/src/components/payment.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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>}
|
||||
|
@ -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"}
|
@ -1 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.6.2"}
|
||||
{"root":["./vite.config.ts"],"errors":true,"version":"5.8.3"}
|
Reference in New Issue
Block a user