mirror of
https://github.com/v0l/route96.git
synced 2025-06-14 23:46:34 +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;
|
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 {
|
export class Route96 {
|
||||||
constructor(
|
constructor(
|
||||||
readonly url: string,
|
readonly url: string,
|
||||||
@ -67,6 +85,36 @@ export class Route96 {
|
|||||||
return data;
|
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) {
|
async #handleResponse<T extends AdminResponseBase>(rsp: Response) {
|
||||||
if (rsp.ok) {
|
if (rsp.ok) {
|
||||||
return (await rsp.json()) as T;
|
return (await rsp.json()) as T;
|
||||||
@ -96,13 +144,19 @@ export class Route96 {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const u = `${this.url}${path}`;
|
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, {
|
return await fetch(u, {
|
||||||
method,
|
method,
|
||||||
body,
|
body,
|
||||||
headers: {
|
headers,
|
||||||
accept: "application/json",
|
|
||||||
authorization: await auth(u, method),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Button from "../components/button";
|
import Button from "../components/button";
|
||||||
import FileList from "./files";
|
import FileList from "./files";
|
||||||
|
import ReportList from "./reports";
|
||||||
|
import PaymentFlow from "../components/payment";
|
||||||
import { openFile } from "../upload";
|
import { openFile } from "../upload";
|
||||||
import { Blossom } from "../upload/blossom";
|
import { Blossom } from "../upload/blossom";
|
||||||
import useLogin from "../hooks/login";
|
import useLogin from "../hooks/login";
|
||||||
import usePublisher from "../hooks/publisher";
|
import usePublisher from "../hooks/publisher";
|
||||||
import { Nip96, Nip96FileList } from "../upload/nip96";
|
import { Nip96, Nip96FileList } from "../upload/nip96";
|
||||||
import { AdminSelf, Route96 } from "../upload/admin";
|
import { AdminSelf, Route96, Report } from "../upload/admin";
|
||||||
import { FormatBytes } from "../const";
|
import { FormatBytes } from "../const";
|
||||||
|
|
||||||
export default function Upload() {
|
export default function Upload() {
|
||||||
@ -18,9 +20,13 @@ export default function Upload() {
|
|||||||
const [results, setResults] = useState<Array<object>>([]);
|
const [results, setResults] = useState<Array<object>>([]);
|
||||||
const [listedFiles, setListedFiles] = useState<Nip96FileList>();
|
const [listedFiles, setListedFiles] = useState<Nip96FileList>();
|
||||||
const [adminListedFiles, setAdminListedFiles] = 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 [listedPage, setListedPage] = useState(0);
|
||||||
const [adminListedPage, setAdminListedPage] = useState(0);
|
const [adminListedPage, setAdminListedPage] = useState(0);
|
||||||
const [mimeFilter, setMimeFilter] = useState<string>();
|
const [mimeFilter, setMimeFilter] = useState<string>();
|
||||||
|
const [showPaymentFlow, setShowPaymentFlow] = useState(false);
|
||||||
|
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const pub = usePublisher();
|
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) {
|
async function deleteFile(id: string) {
|
||||||
if (!pub) return;
|
if (!pub) return;
|
||||||
try {
|
try {
|
||||||
@ -111,12 +154,22 @@ export default function Upload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listUploads(listedPage);
|
if (pub) {
|
||||||
}, [listedPage]);
|
listUploads(listedPage);
|
||||||
|
}
|
||||||
|
}, [listedPage, pub]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listAllUploads(adminListedPage);
|
if (pub) {
|
||||||
}, [adminListedPage, mimeFilter]);
|
listAllUploads(adminListedPage);
|
||||||
|
}
|
||||||
|
}, [adminListedPage, mimeFilter, pub]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pub && self?.is_admin) {
|
||||||
|
listReports(reportPage);
|
||||||
|
}
|
||||||
|
}, [reportPage, pub, self?.is_admin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pub && !self) {
|
if (pub && !self) {
|
||||||
@ -191,6 +244,55 @@ export default function Upload() {
|
|||||||
</div>
|
</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 && (
|
{listedFiles && (
|
||||||
<FileList
|
<FileList
|
||||||
files={listedFiles.files}
|
files={listedFiles.files}
|
||||||
@ -209,6 +311,7 @@ export default function Upload() {
|
|||||||
<hr />
|
<hr />
|
||||||
<h3>Admin File List:</h3>
|
<h3>Admin File List:</h3>
|
||||||
<Button onClick={() => listAllUploads(0)}>List All Uploads</Button>
|
<Button onClick={() => listAllUploads(0)}>List All Uploads</Button>
|
||||||
|
<Button onClick={() => listReports(0)}>List Reports</Button>
|
||||||
<div>
|
<div>
|
||||||
<select value={mimeFilter} onChange={e => setMimeFilter(e.target.value)}>
|
<select value={mimeFilter} onChange={e => setMimeFilter(e.target.value)}>
|
||||||
<option value={""}>All</option>
|
<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>}
|
{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