feat: filter disk type on templates
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
@ -1,15 +1,27 @@
|
||||
import classNames from "classnames";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function FilterButton({ children, onClick, active }: { children: ReactNode, onClick?: () => Promise<void> | void, active?: boolean }) {
|
||||
return <div
|
||||
className={classNames("rounded-full outline outline-1 px-4 py-1 cursor-pointer select-none",
|
||||
{
|
||||
"bg-neutral-800 outline-neutral-300": active,
|
||||
"bg-neutral-900 outline-neutral-800 text-neutral-500": !active
|
||||
}
|
||||
)}
|
||||
onClick={onClick}>
|
||||
{children}
|
||||
export function FilterButton({
|
||||
children,
|
||||
onClick,
|
||||
active,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onClick?: () => Promise<void> | void;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-full outline outline-1 px-4 py-1 cursor-pointer select-none",
|
||||
{
|
||||
"bg-neutral-800 outline-neutral-300": active,
|
||||
"bg-neutral-900 outline-neutral-800 text-neutral-500": !active,
|
||||
},
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -6,23 +6,38 @@ interface Price {
|
||||
}
|
||||
type Cost = Price & { interval_type?: string };
|
||||
|
||||
export default function CostLabel({ cost }: { cost: Cost & { other_price?: Array<Price> } }) {
|
||||
export default function CostLabel({
|
||||
cost,
|
||||
}: {
|
||||
cost: Cost & { other_price?: Array<Price> };
|
||||
}) {
|
||||
const login = useLogin();
|
||||
|
||||
if (cost.currency === login?.currency) {
|
||||
return <CostAmount cost={cost} converted={false} />
|
||||
return <CostAmount cost={cost} converted={false} />;
|
||||
} else {
|
||||
const converted_price = cost.other_price?.find((p) => p.currency === login?.currency);
|
||||
const converted_price = cost.other_price?.find(
|
||||
(p) => p.currency === login?.currency,
|
||||
);
|
||||
if (converted_price) {
|
||||
return <div>
|
||||
<CostAmount cost={{
|
||||
...converted_price,
|
||||
interval_type: cost.interval_type
|
||||
}} converted={true} />
|
||||
<CostAmount cost={cost} converted={false} className="text-sm text-neutral-400" />
|
||||
</div>
|
||||
return (
|
||||
<div>
|
||||
<CostAmount
|
||||
cost={{
|
||||
...converted_price,
|
||||
interval_type: cost.interval_type,
|
||||
}}
|
||||
converted={true}
|
||||
/>
|
||||
<CostAmount
|
||||
cost={cost}
|
||||
converted={false}
|
||||
className="text-sm text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <CostAmount cost={cost} converted={false} />
|
||||
return <CostAmount cost={cost} converted={false} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,11 +53,19 @@ function intervalName(n: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function CostAmount({ cost, converted, className }: { cost: Cost, converted: boolean, className?: string }) {
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
export function CostAmount({
|
||||
cost,
|
||||
converted,
|
||||
className,
|
||||
}: {
|
||||
cost: Cost;
|
||||
converted: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const formatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: cost.currency,
|
||||
trailingZeroDisplay: 'stripIfInteger'
|
||||
trailingZeroDisplay: "stripIfInteger",
|
||||
});
|
||||
return (
|
||||
<div className={className}>
|
||||
@ -54,4 +77,4 @@ export function CostAmount({ cost, converted, className }: { cost: Cost, convert
|
||||
{cost.interval_type && <>/{intervalName(cost.interval_type)}</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -3,63 +3,65 @@ import { useEffect, useRef } from "react";
|
||||
import { VmCostPlan } from "../api";
|
||||
|
||||
interface RevolutProps {
|
||||
amount: VmCostPlan | {
|
||||
amount:
|
||||
| VmCostPlan
|
||||
| {
|
||||
amount: number;
|
||||
currency: string;
|
||||
tax?: number;
|
||||
};
|
||||
pubkey: string;
|
||||
loadOrder: () => Promise<string>;
|
||||
onPaid: () => void;
|
||||
onCancel?: () => void;
|
||||
mode?: string;
|
||||
};
|
||||
pubkey: string;
|
||||
loadOrder: () => Promise<string>;
|
||||
onPaid: () => void;
|
||||
onCancel?: () => void;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export function RevolutPayWidget({
|
||||
pubkey,
|
||||
loadOrder,
|
||||
amount,
|
||||
onPaid,
|
||||
onCancel,
|
||||
mode,
|
||||
pubkey,
|
||||
loadOrder,
|
||||
amount,
|
||||
onPaid,
|
||||
onCancel,
|
||||
mode,
|
||||
}: RevolutProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
async function load(pubkey: string, ref: HTMLDivElement) {
|
||||
const { revolutPay } = await RevolutCheckout.payments({
|
||||
locale: "auto",
|
||||
mode: (mode ?? "prod") as Mode,
|
||||
publicToken: pubkey,
|
||||
});
|
||||
ref.innerHTML = "";
|
||||
revolutPay.mount(ref, {
|
||||
currency: amount.currency,
|
||||
totalAmount: amount.amount,
|
||||
createOrder: async () => {
|
||||
const id = await loadOrder();
|
||||
return {
|
||||
publicId: id,
|
||||
};
|
||||
},
|
||||
buttonStyle: {
|
||||
cashback: false,
|
||||
},
|
||||
});
|
||||
revolutPay.on("payment", (payload) => {
|
||||
console.debug(payload);
|
||||
if (payload.type === "success") {
|
||||
onPaid();
|
||||
}
|
||||
if (payload.type === "cancel") {
|
||||
onCancel?.();
|
||||
}
|
||||
});
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
async function load(pubkey: string, ref: HTMLDivElement) {
|
||||
const { revolutPay } = await RevolutCheckout.payments({
|
||||
locale: "auto",
|
||||
mode: (mode ?? "prod") as Mode,
|
||||
publicToken: pubkey,
|
||||
});
|
||||
ref.innerHTML = "";
|
||||
revolutPay.mount(ref, {
|
||||
currency: amount.currency,
|
||||
totalAmount: amount.amount,
|
||||
createOrder: async () => {
|
||||
const id = await loadOrder();
|
||||
return {
|
||||
publicId: id,
|
||||
};
|
||||
},
|
||||
buttonStyle: {
|
||||
cashback: false,
|
||||
},
|
||||
});
|
||||
revolutPay.on("payment", (payload) => {
|
||||
console.debug(payload);
|
||||
if (payload.type === "success") {
|
||||
onPaid();
|
||||
}
|
||||
if (payload.type === "cancel") {
|
||||
onCancel?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
load(pubkey, ref.current);
|
||||
}
|
||||
}, [pubkey, ref]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
load(pubkey, ref.current);
|
||||
}
|
||||
}, [pubkey, ref]);
|
||||
|
||||
return <div ref={ref}></div>;
|
||||
return <div ref={ref}></div>;
|
||||
}
|
||||
|
@ -46,19 +46,19 @@ export default function VmActions({
|
||||
title="Reinstall"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if(confirm("Are you sure you want to re-install your vm?\nTHIS WILL DELETE ALL DATA!!")) {
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to re-install your vm?\nTHIS WILL DELETE ALL DATA!!",
|
||||
)
|
||||
) {
|
||||
await login?.api.reisntallVm(vm.id);
|
||||
onReload?.();
|
||||
}
|
||||
}}
|
||||
className="bg-neutral-700 hover:bg-neutral-600"
|
||||
>
|
||||
<Icon
|
||||
name="refresh-1"
|
||||
size={30}
|
||||
/>
|
||||
<Icon name="refresh-1" size={30} />
|
||||
</AsyncButton>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -8,9 +8,7 @@ export default function VpsCard({ spec }: { spec: VmTemplate }) {
|
||||
<div className="rounded-xl border border-neutral-600 px-3 py-2 flex flex-col gap-2">
|
||||
<div className="text-xl">{spec.name}</div>
|
||||
<ul>
|
||||
<li>
|
||||
CPU: {spec.cpu}vCPU
|
||||
</li>
|
||||
<li>CPU: {spec.cpu}vCPU</li>
|
||||
<li>
|
||||
RAM: <BytesSize value={spec.memory} />
|
||||
</li>
|
||||
@ -19,7 +17,9 @@ export default function VpsCard({ spec }: { spec: VmTemplate }) {
|
||||
</li>
|
||||
<li>Location: {spec.region?.name}</li>
|
||||
</ul>
|
||||
<div className="text-lg">{spec.cost_plan && <CostLabel cost={spec.cost_plan} />}</div>
|
||||
<div className="text-lg">
|
||||
{spec.cost_plan && <CostLabel cost={spec.cost_plan} />}
|
||||
</div>
|
||||
<VpsPayButton spec={spec} />
|
||||
</div>
|
||||
);
|
||||
|
@ -21,8 +21,9 @@ export function VpsCustomOrder({
|
||||
const [cpu, setCpu] = useState(params.min_cpu ?? 1);
|
||||
const [diskType, setDiskType] = useState(params.disks.at(0));
|
||||
const [ram, setRam] = useState(Math.floor((params.min_memory ?? GiB) / GiB));
|
||||
const [disk, setDisk] = useState(Math.floor((diskType?.min_disk ?? GiB) / GiB));
|
||||
|
||||
const [disk, setDisk] = useState(
|
||||
Math.floor((diskType?.min_disk ?? GiB) / GiB),
|
||||
);
|
||||
|
||||
const [price, setPrice] = useState<VmCustomPrice>();
|
||||
|
||||
@ -57,14 +58,18 @@ export function VpsCustomOrder({
|
||||
return (
|
||||
<div className="flex flex-col gap-4 bg-neutral-900 rounded-xl px-4 py-6">
|
||||
<div className="text-lg">Custom VPS Order</div>
|
||||
{params.disks.length > 1 && <div className="flex gap-2">
|
||||
{params.disks.map((d) =>
|
||||
<FilterButton active={diskType?.disk_type === d.disk_type}
|
||||
onClick={() => setDiskType(d)}>
|
||||
{d.disk_type.toUpperCase()}
|
||||
</FilterButton>
|
||||
)}
|
||||
</div>}
|
||||
{params.disks.length > 1 && (
|
||||
<div className="flex gap-2">
|
||||
{params.disks.map((d) => (
|
||||
<FilterButton
|
||||
active={diskType?.disk_type === d.disk_type}
|
||||
onClick={() => setDiskType(d)}
|
||||
>
|
||||
{d.disk_type.toUpperCase()}
|
||||
</FilterButton>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="min-w-[100px]">{cpu} CPU</div>
|
||||
<input
|
||||
|
@ -49,10 +49,14 @@ export default function VpsPayment({
|
||||
className="cursor-pointer rounded-xl overflow-hidden"
|
||||
/>
|
||||
<div className="flex flex-col items-center">
|
||||
<div>{((payment.amount + payment.tax) / 1000).toLocaleString()} sats</div>
|
||||
{payment.tax > 0 && <div className="text-xs">
|
||||
including {(payment.tax / 1000).toLocaleString()} sats tax
|
||||
</div>}
|
||||
<div>
|
||||
{((payment.amount + payment.tax) / 1000).toLocaleString()} sats
|
||||
</div>
|
||||
{payment.tax > 0 && (
|
||||
<div className="text-xs">
|
||||
including {(payment.tax / 1000).toLocaleString()} sats tax
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="monospace select-all break-all text-center text-sm">
|
||||
{invoice}
|
||||
|
@ -14,14 +14,15 @@ export default function useLogin() {
|
||||
() =>
|
||||
session
|
||||
? {
|
||||
type: session.type,
|
||||
publicKey: session.publicKey,
|
||||
system,
|
||||
currency: session.currency,
|
||||
api: new LNVpsApi(ApiUrl, LoginState.getSigner()),
|
||||
update: (fx: (ses: LoginSession) => void) => LoginState.updateSession(fx),
|
||||
logout: () => LoginState.logout(),
|
||||
}
|
||||
type: session.type,
|
||||
publicKey: session.publicKey,
|
||||
system,
|
||||
currency: session.currency,
|
||||
api: new LNVpsApi(ApiUrl, LoginState.getSigner()),
|
||||
update: (fx: (ses: LoginSession) => void) =>
|
||||
LoginState.updateSession(fx),
|
||||
logout: () => LoginState.logout(),
|
||||
}
|
||||
: undefined,
|
||||
[session, system],
|
||||
);
|
||||
|
@ -43,7 +43,7 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
|
||||
this.#session = {
|
||||
type: type ?? "nip7",
|
||||
publicKey: pubkey,
|
||||
currency: "EUR"
|
||||
currency: "EUR",
|
||||
};
|
||||
this.#save();
|
||||
}
|
||||
@ -54,7 +54,7 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
|
||||
type: "nsec",
|
||||
publicKey: s.getPubKey(),
|
||||
privateKey: key,
|
||||
currency: "EUR"
|
||||
currency: "EUR",
|
||||
};
|
||||
this.#save();
|
||||
}
|
||||
@ -65,7 +65,7 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
|
||||
publicKey: remotePubkey,
|
||||
privateKey: localKey,
|
||||
bunker: url,
|
||||
currency: "EUR"
|
||||
currency: "EUR",
|
||||
};
|
||||
this.#save();
|
||||
}
|
||||
|
@ -15,72 +15,77 @@ export function AccountSettings() {
|
||||
}, [login]);
|
||||
|
||||
if (!acc) return;
|
||||
return <div className="flex flex-col gap-4">
|
||||
<h3>
|
||||
Account Settings
|
||||
</h3>
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3>Account Settings</h3>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<h4>Country</h4>
|
||||
<select value={acc?.country_code}
|
||||
onChange={(e) =>
|
||||
setAcc((s) => (s ? { ...s, country_code: e.target.value } : undefined))
|
||||
}
|
||||
>
|
||||
{iso.all().map(c => <option value={c.alpha3}>{c.country}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h3>Notification Settings</h3>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acc?.contact_email ?? false}
|
||||
onChange={(e) => {
|
||||
setAcc((s) =>
|
||||
s ? { ...s, contact_email: e.target.checked } : undefined,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
Email
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acc?.contact_nip17 ?? false}
|
||||
onChange={(e) => {
|
||||
setAcc((s) =>
|
||||
s ? { ...s, contact_nip17: e.target.checked } : undefined,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
Nostr DM
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<h4>Email</h4>
|
||||
<input
|
||||
type="text"
|
||||
disabled={!editEmail}
|
||||
value={acc?.email}
|
||||
onChange={(e) =>
|
||||
setAcc((s) => (s ? { ...s, email: e.target.value } : undefined))
|
||||
}
|
||||
/>
|
||||
{!editEmail && (
|
||||
<Icon name="pencil" onClick={() => setEditEmail(true)} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
if (login?.api && acc) {
|
||||
await login.api.updateAccount(acc);
|
||||
const newAcc = await login.api.getAccount();
|
||||
setAcc(newAcc);
|
||||
setEditEmail(false);
|
||||
<div className="flex gap-2 items-center">
|
||||
<h4>Country</h4>
|
||||
<select
|
||||
value={acc?.country_code}
|
||||
onChange={(e) =>
|
||||
setAcc((s) =>
|
||||
s ? { ...s, country_code: e.target.value } : undefined,
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</AsyncButton>
|
||||
>
|
||||
{iso.all().map((c) => (
|
||||
<option value={c.alpha3}>{c.country}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h3>Notification Settings</h3>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acc?.contact_email ?? false}
|
||||
onChange={(e) => {
|
||||
setAcc((s) =>
|
||||
s ? { ...s, contact_email: e.target.checked } : undefined,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
Email
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acc?.contact_nip17 ?? false}
|
||||
onChange={(e) => {
|
||||
setAcc((s) =>
|
||||
s ? { ...s, contact_nip17: e.target.checked } : undefined,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
Nostr DM
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<h4>Email</h4>
|
||||
<input
|
||||
type="text"
|
||||
disabled={!editEmail}
|
||||
value={acc?.email}
|
||||
onChange={(e) =>
|
||||
setAcc((s) => (s ? { ...s, email: e.target.value } : undefined))
|
||||
}
|
||||
/>
|
||||
{!editEmail && (
|
||||
<Icon name="pencil" onClick={() => setEditEmail(true)} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
if (login?.api && acc) {
|
||||
await login.api.updateAccount(acc);
|
||||
const newAcc = await login.api.getAccount();
|
||||
setAcc(newAcc);
|
||||
setEditEmail(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,31 +1,37 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { LNVpsApi, VmHostRegion, VmTemplateResponse } from "../api";
|
||||
import { useState, useEffect, ReactNode } from "react";
|
||||
import { DiskType, LNVpsApi, VmHostRegion, VmTemplateResponse } from "../api";
|
||||
import VpsCard from "../components/vps-card";
|
||||
import { ApiUrl, NostrProfile } from "../const";
|
||||
import { Link } from "react-router-dom";
|
||||
import { VpsCustomOrder } from "../components/vps-custom";
|
||||
import { LatestNews } from "../components/latest-news";
|
||||
import { FilterButton } from "../components/button-filter";
|
||||
import { dedupe } from "@snort/shared";
|
||||
import { appendDedupe, dedupe } from "@snort/shared";
|
||||
import useLogin from "../hooks/login";
|
||||
|
||||
export default function HomePage() {
|
||||
const login = useLogin();
|
||||
const [offers, setOffers] = useState<VmTemplateResponse>();
|
||||
const [region, setRegion] = useState<Array<number>>([]);
|
||||
const [diskType, setDiskType] = useState<Array<DiskType>>([]);
|
||||
|
||||
const regions = (offers?.templates.map((t) => t.region) ?? []).reduce((acc, v) => {
|
||||
if (acc[v.id] === undefined) {
|
||||
acc[v.id] = v;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<number, VmHostRegion>);
|
||||
const regions = (offers?.templates.map((t) => t.region) ?? []).reduce(
|
||||
(acc, v) => {
|
||||
if (acc[v.id] === undefined) {
|
||||
acc[v.id] = v;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, VmHostRegion>,
|
||||
);
|
||||
const diskTypes = dedupe(offers?.templates.map((t) => t.disk_type) ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
const api = new LNVpsApi(ApiUrl, undefined);
|
||||
api.listOffers().then((o) => {
|
||||
setOffers(o)
|
||||
setOffers(o);
|
||||
setRegion(dedupe(o.templates.map((z) => z.region.id)));
|
||||
setDiskType(dedupe(o.templates.map((z) => z.disk_type)));
|
||||
});
|
||||
}, []);
|
||||
|
||||
@ -38,24 +44,58 @@ export default function HomePage() {
|
||||
Virtual Private Server hosting with flexible plans, high uptime, and
|
||||
dedicated support, tailored to your needs.
|
||||
</div>
|
||||
{Object.keys(regions).length > 1 && <div className="flex gap-2 items-center">
|
||||
Regions:
|
||||
{Object.values(regions).map((r) => {
|
||||
return <FilterButton
|
||||
active={region.includes(r.id)}
|
||||
onClick={() => setRegion(x => {
|
||||
if (x.includes(r.id)) {
|
||||
return x.filter(y => y != r.id);
|
||||
} else {
|
||||
return [...x, r.id];
|
||||
}
|
||||
})}>
|
||||
{r.name}
|
||||
</FilterButton>;
|
||||
})}
|
||||
</div>}
|
||||
<div className="flex gap-4 items-center">
|
||||
{Object.keys(regions).length > 1 && (
|
||||
<FilterSection header={"Region"}>
|
||||
{Object.values(regions).map((r) => {
|
||||
return (
|
||||
<FilterButton
|
||||
active={region.includes(r.id)}
|
||||
onClick={() =>
|
||||
setRegion((x) => {
|
||||
if (x.includes(r.id)) {
|
||||
return x.filter((y) => y != r.id);
|
||||
} else {
|
||||
return appendDedupe(x, [r.id]);
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{r.name}
|
||||
</FilterButton>
|
||||
);
|
||||
})}
|
||||
</FilterSection>
|
||||
)}
|
||||
{diskTypes.length > 1 && (
|
||||
<FilterSection header={"Disk"}>
|
||||
{diskTypes.map((d) => (
|
||||
<FilterButton
|
||||
active={diskType.includes(d)}
|
||||
onClick={() => {
|
||||
setDiskType((s) => {
|
||||
if (s?.includes(d)) {
|
||||
return s.filter((y) => y !== d);
|
||||
} else {
|
||||
return appendDedupe(s, [d]);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{d.toUpperCase()}
|
||||
</FilterButton>
|
||||
))}
|
||||
</FilterSection>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{offers?.templates.filter((t) => region.includes(t.region.id)).sort((a, b) => a.cost_plan.amount - b.cost_plan.amount).map((a) => <VpsCard spec={a} key={a.id} />)}
|
||||
{offers?.templates
|
||||
.filter(
|
||||
(t) =>
|
||||
region.includes(t.region.id) && diskType.includes(t.disk_type),
|
||||
)
|
||||
.sort((a, b) => a.cost_plan.amount - b.cost_plan.amount)
|
||||
.map((a) => <VpsCard spec={a} key={a.id} />)}
|
||||
{offers?.templates !== undefined && offers.templates.length === 0 && (
|
||||
<div className="text-red-500 bold text-xl uppercase">
|
||||
No offers available
|
||||
@ -66,7 +106,8 @@ export default function HomePage() {
|
||||
<VpsCustomOrder templates={offers.custom_template} />
|
||||
)}
|
||||
<small className="text-neutral-400 text-center">
|
||||
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic, all prices are excluding taxes.
|
||||
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic,
|
||||
all prices are excluding taxes.
|
||||
</small>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-center">
|
||||
@ -93,18 +134,29 @@ export default function HomePage() {
|
||||
Speedtest
|
||||
</a>
|
||||
</div>
|
||||
{import.meta.env.VITE_FOOTER_NOTE_1 && <div className="text-xs text-center text-neutral-400">
|
||||
{import.meta.env.VITE_FOOTER_NOTE_1}
|
||||
</div>}
|
||||
{import.meta.env.VITE_FOOTER_NOTE_2 && <div className="text-xs text-center text-neutral-400">
|
||||
{import.meta.env.VITE_FOOTER_NOTE_2}
|
||||
</div>}
|
||||
{import.meta.env.VITE_FOOTER_NOTE_1 && (
|
||||
<div className="text-xs text-center text-neutral-400">
|
||||
{import.meta.env.VITE_FOOTER_NOTE_1}
|
||||
</div>
|
||||
)}
|
||||
{import.meta.env.VITE_FOOTER_NOTE_2 && (
|
||||
<div className="text-xs text-center text-neutral-400">
|
||||
{import.meta.env.VITE_FOOTER_NOTE_2}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-center">
|
||||
Currency:
|
||||
{" "}
|
||||
<select value={login?.currency ?? "EUR"}
|
||||
onChange={(e) => login?.update(s => s.currency = e.target.value)}>
|
||||
{["BTC", "EUR", "USD", "GBP", "AUD", "CAD", "CHF", "JPY"].map((a) => <option>{a}</option>)}
|
||||
Currency:{" "}
|
||||
<select
|
||||
value={login?.currency ?? "EUR"}
|
||||
onChange={(e) =>
|
||||
login?.update((s) => (s.currency = e.target.value))
|
||||
}
|
||||
>
|
||||
{["BTC", "EUR", "USD", "GBP", "AUD", "CAD", "CHF", "JPY"].map(
|
||||
(a) => (
|
||||
<option>{a}</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@ -112,3 +164,18 @@ export default function HomePage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSection({
|
||||
header,
|
||||
children,
|
||||
}: {
|
||||
header?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 bg-neutral-900 px-3 py-2 rounded-xl">
|
||||
<div className="text-md text-neutral-400">{header}</div>
|
||||
<div className="flex gap-2 items-center">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -10,47 +10,48 @@ import { AttachAddon } from "@xterm/addon-attach";
|
||||
const fit = new FitAddon();
|
||||
|
||||
export function VmConsolePage() {
|
||||
const { state } = useLocation() as { state?: VmInstance };
|
||||
const login = useLogin();
|
||||
const [term, setTerm] = useState<Terminal>();
|
||||
const termRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { state } = useLocation() as { state?: VmInstance };
|
||||
const login = useLogin();
|
||||
const [term, setTerm] = useState<Terminal>();
|
||||
const termRef = useRef<HTMLDivElement | null>(null);
|
||||
async function openTerminal() {
|
||||
if (!login?.api || !state) return;
|
||||
const ws = await login.api.connect_terminal(state.id);
|
||||
const te = new Terminal();
|
||||
const webgl = new WebglAddon();
|
||||
webgl.onContextLoss(() => {
|
||||
webgl.dispose();
|
||||
});
|
||||
te.loadAddon(webgl);
|
||||
te.loadAddon(fit);
|
||||
const attach = new AttachAddon(ws);
|
||||
attach.activate(te);
|
||||
setTerm((t) => {
|
||||
if (t) {
|
||||
t.dispose();
|
||||
}
|
||||
return te;
|
||||
});
|
||||
}
|
||||
|
||||
async function openTerminal() {
|
||||
if (!login?.api || !state) return;
|
||||
const ws = await login.api.connect_terminal(state.id);
|
||||
const te = new Terminal();
|
||||
const webgl = new WebglAddon();
|
||||
webgl.onContextLoss(() => {
|
||||
webgl.dispose();
|
||||
});
|
||||
te.loadAddon(webgl);
|
||||
te.loadAddon(fit);
|
||||
const attach = new AttachAddon(ws);
|
||||
attach.activate(te);
|
||||
setTerm((t) => {
|
||||
if (t) {
|
||||
t.dispose();
|
||||
}
|
||||
return te
|
||||
});
|
||||
useEffect(() => {
|
||||
if (term && termRef.current) {
|
||||
termRef.current.innerHTML = "";
|
||||
term.open(termRef.current);
|
||||
term.focus();
|
||||
fit.fit();
|
||||
}
|
||||
}, [termRef, term]);
|
||||
|
||||
useEffect(() => {
|
||||
if (term && termRef.current) {
|
||||
termRef.current.innerHTML = "";
|
||||
term.open(termRef.current);
|
||||
term.focus();
|
||||
fit.fit();
|
||||
}
|
||||
}, [termRef, term]);
|
||||
useEffect(() => {
|
||||
openTerminal();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
openTerminal();
|
||||
}, []);
|
||||
|
||||
return <div className="flex flex-col gap-4">
|
||||
<div className="text-xl">VM #{state?.id} Terminal:</div>
|
||||
{term && <div className="border p-2" ref={termRef}></div>}
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-xl">VM #{state?.id} Terminal:</div>
|
||||
{term && <div className="border p-2" ref={termRef}></div>}
|
||||
</div>
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -10,8 +10,6 @@ import { Icon } from "../components/icon";
|
||||
import Modal from "../components/modal";
|
||||
import SSHKeySelector from "../components/ssh-keys";
|
||||
|
||||
|
||||
|
||||
export default function VmPage() {
|
||||
const location = useLocation() as { state?: VmInstance };
|
||||
const login = useLogin();
|
||||
@ -66,14 +64,9 @@ export default function VmPage() {
|
||||
if ((state.ip_assignments?.length ?? 0) === 0) {
|
||||
return <div className="text-sm text-red-500">No IP's assigned</div>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{state.ip_assignments?.map((i) => ipRow(i, true))}
|
||||
</>
|
||||
);
|
||||
return <>{state.ip_assignments?.map((i) => ipRow(i, true))}</>;
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => reloadVmState(), 5000);
|
||||
return () => clearInterval(t);
|
||||
|
Reference in New Issue
Block a user