feat: backend api
This commit is contained in:
@ -1,15 +1,25 @@
|
||||
import classNames from "classnames";
|
||||
import { forwardRef, HTMLProps } from "react";
|
||||
|
||||
export type AsyncButtonProps = {
|
||||
onClick?: (e: React.MouseEvent) => Promise<void>;
|
||||
onClick?: (e: React.MouseEvent) => Promise<void> | void;
|
||||
} & Omit<HTMLProps<HTMLButtonElement>, "type" | "ref" | "onClick">;
|
||||
|
||||
const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>(
|
||||
function AsyncButton(props, ref) {
|
||||
function AsyncButton({ className, ...props }, ref) {
|
||||
const hasBg = className?.includes("bg-");
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className="bg-slate-700 py-1 px-2 rounded-xl"
|
||||
className={classNames(
|
||||
"py-1 px-2 rounded-xl font-medium",
|
||||
{
|
||||
"bg-neutral-800 cursor-not-allowed text-neutral-500":
|
||||
!hasBg && props.disabled === true,
|
||||
"bg-neutral-900": !hasBg && !props.disabled,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
|
@ -1,22 +1,20 @@
|
||||
import { CostInterval, MachineSpec } from "../api";
|
||||
import { VmCostPlan } from "../api";
|
||||
|
||||
export default function CostLabel({ cost }: { cost: MachineSpec["cost"] }) {
|
||||
function intervalName(n: number) {
|
||||
export default function CostLabel({ cost }: { cost: VmCostPlan }) {
|
||||
function intervalName(n: string) {
|
||||
switch (n) {
|
||||
case CostInterval.Hour:
|
||||
return "Hour";
|
||||
case CostInterval.Day:
|
||||
case "day":
|
||||
return "Day";
|
||||
case CostInterval.Month:
|
||||
case "month":
|
||||
return "Month";
|
||||
case CostInterval.Year:
|
||||
case "year":
|
||||
return "Year";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{cost.count} {cost.currency}/{intervalName(cost.interval)}
|
||||
{cost.amount} {cost.currency}/{intervalName(cost.interval_type)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
26
src/components/icon.tsx
Normal file
26
src/components/icon.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { MouseEventHandler } from "react";
|
||||
|
||||
import Icons from "../icons.svg";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
onClick?: MouseEventHandler<SVGSVGElement>;
|
||||
};
|
||||
|
||||
export function Icon(props: Props) {
|
||||
const size = props.size || 20;
|
||||
const href = `${Icons}#${props.name}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className={props.className}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<use href={href} />
|
||||
</svg>
|
||||
);
|
||||
}
|
@ -5,6 +5,7 @@ import { loginNip7 } from "../login";
|
||||
import useLogin from "../hooks/login";
|
||||
import Profile from "./profile";
|
||||
import { NostrLink } from "@snort/system";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function LoginButton() {
|
||||
const system = useContext(SnortContext);
|
||||
@ -19,6 +20,8 @@ export default function LoginButton() {
|
||||
Sign In
|
||||
</AsyncButton>
|
||||
) : (
|
||||
<Profile link={NostrLink.publicKey(login.pubkey)} />
|
||||
<Link to="/account">
|
||||
<Profile link={NostrLink.publicKey(login.pubkey)} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
85
src/components/modal.tsx
Normal file
85
src/components/modal.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import classNames from "classnames";
|
||||
import React, { ReactNode, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AsyncButton } from "./button";
|
||||
|
||||
export interface ModalProps {
|
||||
id: string;
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
onClose?: (e: React.MouseEvent | KeyboardEvent) => void;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
children?: ReactNode;
|
||||
showClose?: boolean;
|
||||
ready?: boolean;
|
||||
largeModal?: boolean;
|
||||
}
|
||||
|
||||
export default function Modal(props: ModalProps) {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && props.onClose) {
|
||||
props.onClose(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add("scroll-lock");
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove("scroll-lock");
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
props.onClose?.(e);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={classNames(
|
||||
"z-[42] w-screen h-screen top-0 left-0 fixed bg-black/80 flex justify-center overflow-y-auto",
|
||||
)}
|
||||
onMouseDown={handleBackdropClick}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
props.bodyClassName ??
|
||||
classNames(
|
||||
"relative bg-layer-1 p-8 transition max-xl:rounded-t-3xl xl:rounded-3xl max-xl:mt-auto xl:my-auto max-lg:w-full",
|
||||
{
|
||||
"max-xl:-translate-y-[calc(100vh-100dvh)]": props.ready ?? true,
|
||||
"max-xl:translate-y-[50vh]": !(props.ready ?? true),
|
||||
"lg:w-[50vw]": !(props.largeModal ?? false),
|
||||
"lg:w-[80vw]": props.largeModal ?? false,
|
||||
},
|
||||
)
|
||||
}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{(props.showClose ?? true) && (
|
||||
<div className="absolute right-4 top-4">
|
||||
<AsyncButton
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
props.onClose?.(e);
|
||||
}}
|
||||
className="rounded-full aspect-square bg-layer-2 p-3"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{props.children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
) as JSX.Element;
|
||||
}
|
9
src/components/os-image-name.tsx
Normal file
9
src/components/os-image-name.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { VmOsImage } from "../api";
|
||||
|
||||
export default function OsImageName({ image }: { image: VmOsImage }) {
|
||||
return (
|
||||
<span>
|
||||
{image.distribution} {image.version}
|
||||
</span>
|
||||
);
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
.btcpay-form {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btcpay-form--inline {
|
||||
flex-direction: row;
|
||||
}
|
||||
.btcpay-form--block {
|
||||
flex-direction: column;
|
||||
}
|
||||
.btcpay-form--inline .submit {
|
||||
margin-left: 15px;
|
||||
}
|
||||
.btcpay-form--block select {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.btcpay-form .btcpay-custom-container {
|
||||
text-align: center;
|
||||
}
|
||||
.btcpay-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btcpay-form .plus-minus {
|
||||
cursor: pointer;
|
||||
font-size: 25px;
|
||||
line-height: 25px;
|
||||
background: #dfe0e1;
|
||||
height: 30px;
|
||||
width: 45px;
|
||||
border: none;
|
||||
border-radius: 60px;
|
||||
margin: auto 5px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.btcpay-form select {
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
color: currentColor;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
display: block;
|
||||
padding: 1px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btcpay-form select:hover {
|
||||
border-color: #ccc;
|
||||
}
|
||||
.btcpay-form option {
|
||||
color: #000;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.btcpay-input-price {
|
||||
-moz-appearance: textfield;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
text-align: center;
|
||||
font-size: 25px;
|
||||
margin: auto;
|
||||
border-radius: 5px;
|
||||
line-height: 35px;
|
||||
background: #fff;
|
||||
}
|
||||
.btcpay-input-price::-webkit-outer-spin-button,
|
||||
.btcpay-input-price::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
import { MachineSpec } from "../api";
|
||||
|
||||
import "./pay-button.css";
|
||||
import { ReactNode } from "react";
|
||||
import { VmTemplate } from "../api";
|
||||
import useLogin from "../hooks/login";
|
||||
import { AsyncButton } from "./button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -10,55 +12,33 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export default function VpsPayButton({ spec }: { spec: MachineSpec }) {
|
||||
const serverUrl = "https://btcpay.v0l.io/api/v1/invoices";
|
||||
export default function VpsPayButton({ spec }: { spec: VmTemplate }) {
|
||||
const login = useLogin();
|
||||
const classNames =
|
||||
"w-full text-center text-lg uppercase rounded-xl py-3 font-bold cursor-pointer select-none";
|
||||
const navigte = useNavigate();
|
||||
|
||||
function handleFormSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const form = event.target as HTMLFormElement;
|
||||
const xhttp = new XMLHttpRequest();
|
||||
xhttp.onreadystatechange = function () {
|
||||
if (this.readyState == 4 && this.status == 200 && this.responseText) {
|
||||
window.btcpay?.appendInvoiceFrame(
|
||||
JSON.parse(this.responseText).invoiceId,
|
||||
);
|
||||
}
|
||||
};
|
||||
xhttp.open("POST", serverUrl, true);
|
||||
xhttp.send(new FormData(form));
|
||||
function placeholder(inner: ReactNode) {
|
||||
return <div className={`${classNames} bg-red-900`}>{inner}</div>;
|
||||
}
|
||||
|
||||
if (!spec.active) {
|
||||
return (
|
||||
<div className="text-center text-xl uppercase bg-red-800 rounded-xl py-3 font-bold">
|
||||
Unavailable
|
||||
</div>
|
||||
);
|
||||
if (!spec.enabled) {
|
||||
return placeholder("Unavailable");
|
||||
}
|
||||
|
||||
if (!login) {
|
||||
return placeholder("Please Login");
|
||||
}
|
||||
return (
|
||||
<form
|
||||
method="POST"
|
||||
action={serverUrl}
|
||||
className="btcpay-form btcpay-form--block w-full"
|
||||
onSubmit={handleFormSubmit}
|
||||
<AsyncButton
|
||||
className={`${classNames} bg-green-800`}
|
||||
onClick={() =>
|
||||
navigte("/order", {
|
||||
state: spec,
|
||||
})
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="storeId"
|
||||
value="CdaHy1puLx4kLC9BG3A9mu88XNyLJukMJRuuhAfbDrxg"
|
||||
/>
|
||||
<input type="hidden" name="jsonResponse" value="true" />
|
||||
<input type="hidden" name="orderId" value={spec.id} />
|
||||
<input type="hidden" name="price" value={spec.cost.count} />
|
||||
<input type="hidden" name="currency" value={spec.cost.currency} />
|
||||
<input
|
||||
type="image"
|
||||
className="w-full"
|
||||
name="submit"
|
||||
src="https://btcpay.v0l.io/img/paybutton/pay.svg"
|
||||
alt="Pay with BTCPay Server, a Self-Hosted Bitcoin Payment Processor"
|
||||
/>
|
||||
</form>
|
||||
Buy Now
|
||||
</AsyncButton>
|
||||
);
|
||||
}
|
||||
|
50
src/components/qr.tsx
Normal file
50
src/components/qr.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import QRCodeStyling from "qr-code-styling";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export interface QrCodeProps {
|
||||
data?: string;
|
||||
link?: string;
|
||||
avatar?: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function QrCode(props: QrCodeProps) {
|
||||
const qrRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if ((props.data?.length ?? 0) > 0 && qrRef.current) {
|
||||
const qr = new QRCodeStyling({
|
||||
width: props.width || 256,
|
||||
height: props.height || 256,
|
||||
data: props.data,
|
||||
margin: 5,
|
||||
type: "canvas",
|
||||
image: props.avatar,
|
||||
dotsOptions: {
|
||||
type: "rounded",
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: "extra-rounded",
|
||||
},
|
||||
imageOptions: {
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
});
|
||||
qrRef.current.innerHTML = "";
|
||||
qr.append(qrRef.current);
|
||||
if (props.link) {
|
||||
qrRef.current.onclick = function () {
|
||||
const elm = document.createElement("a");
|
||||
elm.href = props.link ?? "";
|
||||
elm.click();
|
||||
};
|
||||
}
|
||||
} else if (qrRef.current) {
|
||||
qrRef.current.innerHTML = "";
|
||||
}
|
||||
}, [props.data, props.link, props.width, props.height, props.avatar]);
|
||||
|
||||
return <div className={props.className} ref={qrRef}></div>;
|
||||
}
|
28
src/components/vps-actions.tsx
Normal file
28
src/components/vps-actions.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { VmInstance } from "../api";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
export default function VmActions({ vm }: { vm: VmInstance }) {
|
||||
const state = vm.status?.state;
|
||||
if (!state) return;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-2">
|
||||
<Icon
|
||||
name={state === "running" ? "stop" : "start"}
|
||||
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
|
||||
size={40}
|
||||
/>
|
||||
<Icon
|
||||
name="delete"
|
||||
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
|
||||
size={40}
|
||||
/>
|
||||
<Icon
|
||||
name="refresh-1"
|
||||
className="bg-neutral-700 p-2 rounded-lg hover:bg-neutral-600"
|
||||
size={40}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,28 +1,23 @@
|
||||
import { DiskType, MachineSpec } from "../api";
|
||||
import { VmTemplate } from "../api";
|
||||
import BytesSize from "./bytes";
|
||||
import CostLabel from "./cost";
|
||||
import VpsPayButton from "./pay-button";
|
||||
|
||||
export default function VpsCard({ spec }: { spec: MachineSpec }) {
|
||||
export default function VpsCard({ spec }: { spec: VmTemplate }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-neutral-600 px-3 py-2">
|
||||
<h2>{spec.id}</h2>
|
||||
<h2>{spec.name}</h2>
|
||||
<ul>
|
||||
<li>CPU: {spec.cpu}vCPU</li>
|
||||
<li>
|
||||
RAM: <BytesSize value={spec.ram} />
|
||||
RAM: <BytesSize value={spec.memory} />
|
||||
</li>
|
||||
<li>
|
||||
{spec.disk.type === DiskType.SSD ? "SSD" : "HDD"}:{" "}
|
||||
<BytesSize value={spec.disk.size} />
|
||||
</li>
|
||||
<li>
|
||||
Location: {spec.location}
|
||||
{spec.disk_type.toUpperCase()}: <BytesSize value={spec.disk_size} />
|
||||
</li>
|
||||
<li>Location: {spec.region?.name}</li>
|
||||
</ul>
|
||||
<h2>
|
||||
<CostLabel cost={spec.cost} />
|
||||
</h2>
|
||||
<h2>{spec.cost_plan && <CostLabel cost={spec.cost_plan} />}</h2>
|
||||
<VpsPayButton spec={spec} />
|
||||
</div>
|
||||
);
|
||||
|
37
src/components/vps-instance.tsx
Normal file
37
src/components/vps-instance.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { VmInstance } from "../api";
|
||||
import OsImageName from "./os-image-name";
|
||||
import VpsResources from "./vps-resources";
|
||||
import VmActions from "./vps-actions";
|
||||
|
||||
export default function VpsInstanceRow({ vm }: { vm: VmInstance }) {
|
||||
const expires = new Date(vm.expires);
|
||||
const isExpired = expires <= new Date();
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center rounded-xl bg-neutral-900 px-3 py-2 cursor-pointer hover:bg-neutral-800">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<span className="text-sm text-neutral-400">#{vm.id}</span>
|
||||
|
||||
{vm.template?.name}
|
||||
|
||||
<span className="text-sm text-neutral-400">
|
||||
<OsImageName image={vm.image!} />
|
||||
</span>
|
||||
</div>
|
||||
<VpsResources vm={vm} />
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{isExpired && (
|
||||
<>
|
||||
<Link to="/vm/renew" className="text-red-500 text-sm" state={vm}>
|
||||
Expired
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{!isExpired && <VmActions vm={vm} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
58
src/components/vps-payment.tsx
Normal file
58
src/components/vps-payment.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { useEffect } from "react";
|
||||
import { LNVpsApi, VmPayment } from "../api";
|
||||
import QrCode from "./qr";
|
||||
import useLogin from "../hooks/login";
|
||||
import { ApiUrl } from "../const";
|
||||
import { EventPublisher } from "@snort/system";
|
||||
|
||||
export default function VpsPayment({
|
||||
payment,
|
||||
onPaid,
|
||||
}: {
|
||||
payment: VmPayment;
|
||||
onPaid?: () => void;
|
||||
}) {
|
||||
const login = useLogin();
|
||||
const ln = `lightning:${payment.invoice}`;
|
||||
|
||||
async function checkPayment(api: LNVpsApi) {
|
||||
try {
|
||||
const st = await api.paymentStatus(payment.id);
|
||||
if (st.is_paid) {
|
||||
onPaid?.();
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!login?.signer) return;
|
||||
const api = new LNVpsApi(
|
||||
ApiUrl,
|
||||
new EventPublisher(login.signer, login.pubkey),
|
||||
);
|
||||
const tx = setInterval(async () => {
|
||||
if (await checkPayment(api)) {
|
||||
clearInterval(tx);
|
||||
}
|
||||
}, 2_000);
|
||||
return () => clearInterval(tx);
|
||||
}, [login]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-xl p-3 bg-neutral-900 items-center">
|
||||
<QrCode
|
||||
data={ln}
|
||||
link={ln}
|
||||
width={512}
|
||||
height={512}
|
||||
avatar="/logo.jpg"
|
||||
className="cursor-pointer rounded-xl overflow-hidden"
|
||||
/>
|
||||
{(payment.amount / 1000).toLocaleString()} sats
|
||||
</div>
|
||||
);
|
||||
}
|
31
src/components/vps-resources.tsx
Normal file
31
src/components/vps-resources.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { VmInstance, VmTemplate } from "../api";
|
||||
import BytesSize from "./bytes";
|
||||
|
||||
export default function VpsResources({ vm }: { vm: VmInstance | VmTemplate }) {
|
||||
const diskType = "template_id" in vm ? vm.template?.disk_type : vm.disk_type;
|
||||
const region =
|
||||
"region_id" in vm ? vm.region?.name : vm.template?.region?.name;
|
||||
const status = "status" in vm ? vm.status : undefined;
|
||||
return (
|
||||
<>
|
||||
<div className="text-xs text-neutral-400">
|
||||
{vm.cpu} vCPU, <BytesSize value={vm.memory} /> RAM,{" "}
|
||||
<BytesSize value={vm.disk_size} /> {diskType?.toUpperCase()},{" "}
|
||||
{region && <>Location: {region}</>}
|
||||
</div>
|
||||
{status && status.state === "running" && (
|
||||
<div className="text-sm text-neutral-200">
|
||||
<div className="w-2 h-2 rounded-full bg-green-800 inline-block"></div>{" "}
|
||||
{(100 * status.cpu_usage).toFixed(1)}% CPU,{" "}
|
||||
{(100 * status.mem_usage).toFixed(0)}% RAM
|
||||
</div>
|
||||
)}
|
||||
{status && status.state === "stopped" && (
|
||||
<div className="text-sm text-neutral-200">
|
||||
<div className="w-2 h-2 rounded-full bg-red-800 inline-block"></div>{" "}
|
||||
Stopped
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user