This commit is contained in:
62
src/api.ts
62
src/api.ts
@ -174,6 +174,28 @@ export interface PaymentMethod {
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NostrDomainsResponse {
|
||||
domains: Array<NostrDomain>;
|
||||
cname: string;
|
||||
}
|
||||
|
||||
export interface NostrDomain {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
handles: number;
|
||||
created: Date;
|
||||
relays: Array<string>;
|
||||
}
|
||||
|
||||
export interface NostrDomainHandle {
|
||||
id: number;
|
||||
domain_id: number;
|
||||
handle: string;
|
||||
created: Date;
|
||||
pubkey: string;
|
||||
}
|
||||
|
||||
export class LNVpsApi {
|
||||
constructor(
|
||||
readonly url: string,
|
||||
@ -354,6 +376,46 @@ export class LNVpsApi {
|
||||
});
|
||||
}
|
||||
|
||||
async listDomains() {
|
||||
const { data } = await this.#handleResponse<
|
||||
ApiResponse<NostrDomainsResponse>
|
||||
>(await this.#req("/api/v1/nostr/domain", "GET"));
|
||||
return data;
|
||||
}
|
||||
|
||||
async addDomain(domain: string) {
|
||||
const { data } = await this.#handleResponse<ApiResponse<NostrDomain>>(
|
||||
await this.#req("/api/v1/nostr/domain", "POST", { name: domain }),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async listDomainHandles(id: number) {
|
||||
const { data } = await this.#handleResponse<
|
||||
ApiResponse<Array<NostrDomainHandle>>
|
||||
>(await this.#req(`/api/v1/nostr/domain/${id}/handle`, "GET"));
|
||||
return data;
|
||||
}
|
||||
|
||||
async addDomainHandle(domain: number, name: string, pubkey: string) {
|
||||
const { data } = await this.#handleResponse<ApiResponse<NostrDomainHandle>>(
|
||||
await this.#req(`/api/v1/nostr/domain/${domain}/handle`, "POST", {
|
||||
name,
|
||||
pubkey,
|
||||
}),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async deleteDomainHandle(domain_id: number, handle_id: number) {
|
||||
await this.#handleResponse<ApiResponse<void>>(
|
||||
await this.#req(
|
||||
`/api/v1/nostr/domain/${domain_id}/handle/${handle_id}`,
|
||||
"DELETE",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async #handleResponse<T extends ApiResponseBase>(rsp: Response) {
|
||||
if (rsp.ok) {
|
||||
return (await rsp.json()) as T;
|
||||
|
63
src/components/account-domains.tsx
Normal file
63
src/components/account-domains.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { NostrDomainsResponse } from "../api";
|
||||
import useLogin from "../hooks/login";
|
||||
import { AsyncButton } from "./button";
|
||||
import Modal from "./modal";
|
||||
import { NostrDomainRow } from "./nostr-domain-row";
|
||||
|
||||
export function AccountNostrDomains() {
|
||||
const login = useLogin();
|
||||
const [domains, setDomains] = useState<NostrDomainsResponse>();
|
||||
const [addDomain, setAddDomain] = useState(false);
|
||||
const [newDomain, setNewDomain] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (login?.api) {
|
||||
login.api.listDomains().then(setDomains);
|
||||
}
|
||||
}, [login]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3>Nostr Domains</h3>
|
||||
<small>
|
||||
Free NIP-05 hosting, add a CNAME entry pointing to
|
||||
<code className="bg-neutral-900 px-2 py-1 rounded-full select-all">
|
||||
{domains?.cname}
|
||||
</code>
|
||||
</small>
|
||||
{domains?.domains.map((d) => (
|
||||
<NostrDomainRow domain={d} canEdit={true} />
|
||||
))}
|
||||
</div>
|
||||
<AsyncButton onClick={() => setAddDomain(true)}>Add Domain</AsyncButton>
|
||||
{addDomain && (
|
||||
<Modal id="add-nostr-domain" onClose={() => setAddDomain(false)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-xl">Add Nostr Domain</div>
|
||||
<input
|
||||
type="text"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
/>
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
if (newDomain && newDomain.length > 4 && login?.api) {
|
||||
await login.api.addDomain(newDomain);
|
||||
const doms = await login.api.listDomains();
|
||||
setDomains(doms);
|
||||
setNewDomain(undefined);
|
||||
setAddDomain(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -51,7 +51,7 @@ export default function Modal(props: ModalProps) {
|
||||
className={
|
||||
props.bodyClassName ??
|
||||
classNames(
|
||||
"relative bg-neutral-700 p-8 transition max-xl:rounded-t-3xl lg:rounded-3xl max-xl:mt-auto lg:my-auto max-lg:w-full",
|
||||
"relative bg-neutral-800 p-8 transition max-xl:rounded-t-3xl lg:rounded-3xl max-xl:mt-auto lg:my-auto max-lg:w-full",
|
||||
{
|
||||
"max-xl:-translate-y-[calc(100vh-100dvh)]": props.ready ?? true,
|
||||
"max-xl:translate-y-[50vh]": !(props.ready ?? true),
|
||||
|
40
src/components/nostr-domain-row.tsx
Normal file
40
src/components/nostr-domain-row.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { NostrDomain } from "../api";
|
||||
import { AsyncButton } from "./button";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
export function NostrDomainRow({
|
||||
domain,
|
||||
canEdit,
|
||||
}: {
|
||||
domain: NostrDomain;
|
||||
canEdit?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div
|
||||
className="bg-neutral-900 rounded-xl px-2 py-3 flex items-center justify-between"
|
||||
key={domain.id}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>{domain.name}</div>
|
||||
<div className="flex gap-2 items-center text-neutral-400 text-sm">
|
||||
<div>{domain.handles} handles</div>
|
||||
{!domain.enabled && <div className="text-red-500">Inactive</div>}
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<AsyncButton
|
||||
className="bg-neutral-700 hover:bg-neutral-600"
|
||||
onClick={() =>
|
||||
navigate("/account/nostr-domain", {
|
||||
state: domain,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon name={"pencil"} size={30} />
|
||||
</AsyncButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -2,7 +2,13 @@ import { hexToBech32 } from "@snort/shared";
|
||||
import { NostrLink } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
|
||||
export default function Profile({ link }: { link: NostrLink }) {
|
||||
export default function Profile({
|
||||
link,
|
||||
withName,
|
||||
}: {
|
||||
link: NostrLink;
|
||||
withName?: boolean;
|
||||
}) {
|
||||
const profile = useUserProfile(link.id);
|
||||
const name = profile?.display_name ?? profile?.name ?? "";
|
||||
return (
|
||||
@ -11,9 +17,11 @@ export default function Profile({ link }: { link: NostrLink }) {
|
||||
src={profile?.picture}
|
||||
className="w-12 h-12 rounded-full bg-neutral-800 object-cover object-center"
|
||||
/>
|
||||
<div>
|
||||
{name.length > 0 ? name : hexToBech32("npub", link.id).slice(0, 12)}
|
||||
</div>
|
||||
{(withName ?? true) && (
|
||||
<div>
|
||||
{name.length > 0 ? name : hexToBech32("npub", link.id).slice(0, 12)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -4,12 +4,12 @@ import { VmCostPlan } from "../api";
|
||||
|
||||
interface RevolutProps {
|
||||
amount:
|
||||
| VmCostPlan
|
||||
| {
|
||||
amount: number;
|
||||
currency: string;
|
||||
tax?: number;
|
||||
};
|
||||
| VmCostPlan
|
||||
| {
|
||||
amount: number;
|
||||
currency: string;
|
||||
tax?: number;
|
||||
};
|
||||
pubkey: string;
|
||||
loadOrder: () => Promise<string>;
|
||||
onPaid: () => void;
|
||||
|
@ -18,6 +18,7 @@ import { VmGraphsPage } from "./pages/vm-graphs.tsx";
|
||||
import { NewsPage } from "./pages/news.tsx";
|
||||
import { NewsPost } from "./pages/news-post.tsx";
|
||||
import { VmConsolePage } from "./pages/vm-console.tsx";
|
||||
import { AccountNostrDomainPage } from "./pages/account-domain.tsx";
|
||||
|
||||
const system = new NostrSystem({
|
||||
automaticOutboxModel: false,
|
||||
@ -50,6 +51,10 @@ const router = createBrowserRouter([
|
||||
path: "/account/settings",
|
||||
element: <AccountSettings />,
|
||||
},
|
||||
{
|
||||
path: "/account/nostr-domain",
|
||||
element: <AccountNostrDomainPage />,
|
||||
},
|
||||
{
|
||||
path: "/order",
|
||||
element: <OrderPage />,
|
||||
|
126
src/pages/account-domain.tsx
Normal file
126
src/pages/account-domain.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { NostrDomain, NostrDomainHandle } from "../api";
|
||||
import { useEffect, useState } from "react";
|
||||
import useLogin from "../hooks/login";
|
||||
import { AsyncButton } from "../components/button";
|
||||
import { NostrDomainRow } from "../components/nostr-domain-row";
|
||||
import Modal from "../components/modal";
|
||||
import { tryParseNostrLink } from "@snort/system";
|
||||
import { hexToBech32 } from "@snort/shared";
|
||||
import { Icon } from "../components/icon";
|
||||
|
||||
export function AccountNostrDomainPage() {
|
||||
const { state } = useLocation();
|
||||
const login = useLogin();
|
||||
const [handles, setHandles] = useState<Array<NostrDomainHandle>>();
|
||||
const [addHandle, setAddHandle] = useState(false);
|
||||
const [newHandle, setNewHandle] = useState<string>();
|
||||
const [newHandlePubkey, setNewHandlePubkey] = useState<string>();
|
||||
const [newHandleError, setNewHandleError] = useState<string>();
|
||||
const domain = state as NostrDomain;
|
||||
|
||||
useEffect(() => {
|
||||
if (login?.api) {
|
||||
login.api.listDomainHandles(domain.id).then(setHandles);
|
||||
}
|
||||
}, [login]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Link to={"/account"}>< Back</Link>
|
||||
<NostrDomainRow domain={domain} />
|
||||
<div className="text-xl">Handles</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{handles !== undefined && handles.length === 0 && (
|
||||
<div className="text-red-500 text-sm">No Registerd Handles</div>
|
||||
)}
|
||||
{handles?.map((a) => (
|
||||
<div
|
||||
className="flex items-center p-2 rounded-xl bg-neutral-900 justify-between"
|
||||
key={a.id}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>{a.handle}</div>
|
||||
<div className="text-neutral-500 text-sm">
|
||||
{hexToBech32("npub", a.pubkey)}
|
||||
</div>
|
||||
</div>
|
||||
<AsyncButton
|
||||
className="bg-neutral-700 hover:bg-neutral-600"
|
||||
onClick={async () => {
|
||||
if (
|
||||
login?.api &&
|
||||
confirm("Are you sure you want to delete this handle?")
|
||||
) {
|
||||
await login.api.deleteDomainHandle(a.domain_id, a.id);
|
||||
const handles = await login.api.listDomainHandles(domain.id);
|
||||
setHandles(handles);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="delete" size={30} />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<AsyncButton onClick={() => setAddHandle(true)}>Add Handle</AsyncButton>
|
||||
{addHandle && (
|
||||
<Modal id="add-handle" onClose={() => setAddHandle(false)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-xl">Add Handle for {domain.name}</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="name"
|
||||
value={newHandle}
|
||||
onChange={(e) => setNewHandle(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="npub/nprofile/hex"
|
||||
value={newHandlePubkey}
|
||||
onChange={(e) => setNewHandlePubkey(e.target.value)}
|
||||
/>
|
||||
{newHandleError && (
|
||||
<div className="text-red-500">{newHandleError}</div>
|
||||
)}
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
if (
|
||||
login?.api &&
|
||||
newHandle &&
|
||||
newHandle.length > 0 &&
|
||||
newHandlePubkey &&
|
||||
newHandlePubkey.length > 0
|
||||
) {
|
||||
setNewHandleError(undefined);
|
||||
try {
|
||||
const pubkeyHex =
|
||||
tryParseNostrLink(newHandlePubkey)?.id ?? newHandlePubkey;
|
||||
await login.api.addDomainHandle(
|
||||
domain.id,
|
||||
newHandle,
|
||||
pubkeyHex,
|
||||
);
|
||||
const handles = await login.api.listDomainHandles(
|
||||
domain.id,
|
||||
);
|
||||
setHandles(handles);
|
||||
setNewHandle(undefined);
|
||||
setNewHandlePubkey(undefined);
|
||||
setAddHandle(false);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setNewHandleError(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -17,7 +17,7 @@ export function AccountSettings() {
|
||||
if (!acc) return;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3>Account Settings</h3>
|
||||
<div className="text-xl">Account Settings</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<h4>Country</h4>
|
||||
@ -35,7 +35,11 @@ export function AccountSettings() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h3>Notification Settings</h3>
|
||||
<div className="text-xl">Notification Settings</div>
|
||||
<p className="text-neutral-400 text-sm">
|
||||
This is only for account notifications such as VM expiration
|
||||
notifications, we do not send marketing or promotional messages.
|
||||
</p>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
@ -5,6 +5,7 @@ import VpsInstanceRow from "../components/vps-instance";
|
||||
import { hexToBech32 } from "@snort/shared";
|
||||
import { AsyncButton } from "../components/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AccountNostrDomains } from "../components/account-domains";
|
||||
|
||||
export default function AccountPage() {
|
||||
const login = useLogin();
|
||||
@ -58,6 +59,7 @@ export default function AccountPage() {
|
||||
<br />
|
||||
<b>Please include your public key in all communications.</b>
|
||||
</div>
|
||||
{vms.length > 0 && <h3>VPS</h3>}
|
||||
{vms.map((a) => (
|
||||
<VpsInstanceRow
|
||||
key={a.id}
|
||||
@ -69,6 +71,7 @@ export default function AccountPage() {
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<AccountNostrDomains />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user