This commit is contained in:
62
src/api.ts
62
src/api.ts
@ -174,6 +174,28 @@ export interface PaymentMethod {
|
|||||||
metadata?: Record<string, string>;
|
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 {
|
export class LNVpsApi {
|
||||||
constructor(
|
constructor(
|
||||||
readonly url: string,
|
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) {
|
async #handleResponse<T extends ApiResponseBase>(rsp: Response) {
|
||||||
if (rsp.ok) {
|
if (rsp.ok) {
|
||||||
return (await rsp.json()) as T;
|
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={
|
className={
|
||||||
props.bodyClassName ??
|
props.bodyClassName ??
|
||||||
classNames(
|
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-[calc(100vh-100dvh)]": props.ready ?? true,
|
||||||
"max-xl:translate-y-[50vh]": !(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 { NostrLink } from "@snort/system";
|
||||||
import { useUserProfile } from "@snort/system-react";
|
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 profile = useUserProfile(link.id);
|
||||||
const name = profile?.display_name ?? profile?.name ?? "";
|
const name = profile?.display_name ?? profile?.name ?? "";
|
||||||
return (
|
return (
|
||||||
@ -11,9 +17,11 @@ export default function Profile({ link }: { link: NostrLink }) {
|
|||||||
src={profile?.picture}
|
src={profile?.picture}
|
||||||
className="w-12 h-12 rounded-full bg-neutral-800 object-cover object-center"
|
className="w-12 h-12 rounded-full bg-neutral-800 object-cover object-center"
|
||||||
/>
|
/>
|
||||||
<div>
|
{(withName ?? true) && (
|
||||||
{name.length > 0 ? name : hexToBech32("npub", link.id).slice(0, 12)}
|
<div>
|
||||||
</div>
|
{name.length > 0 ? name : hexToBech32("npub", link.id).slice(0, 12)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,12 +4,12 @@ import { VmCostPlan } from "../api";
|
|||||||
|
|
||||||
interface RevolutProps {
|
interface RevolutProps {
|
||||||
amount:
|
amount:
|
||||||
| VmCostPlan
|
| VmCostPlan
|
||||||
| {
|
| {
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
tax?: number;
|
tax?: number;
|
||||||
};
|
};
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
loadOrder: () => Promise<string>;
|
loadOrder: () => Promise<string>;
|
||||||
onPaid: () => void;
|
onPaid: () => void;
|
||||||
|
@ -18,6 +18,7 @@ import { VmGraphsPage } from "./pages/vm-graphs.tsx";
|
|||||||
import { NewsPage } from "./pages/news.tsx";
|
import { NewsPage } from "./pages/news.tsx";
|
||||||
import { NewsPost } from "./pages/news-post.tsx";
|
import { NewsPost } from "./pages/news-post.tsx";
|
||||||
import { VmConsolePage } from "./pages/vm-console.tsx";
|
import { VmConsolePage } from "./pages/vm-console.tsx";
|
||||||
|
import { AccountNostrDomainPage } from "./pages/account-domain.tsx";
|
||||||
|
|
||||||
const system = new NostrSystem({
|
const system = new NostrSystem({
|
||||||
automaticOutboxModel: false,
|
automaticOutboxModel: false,
|
||||||
@ -50,6 +51,10 @@ const router = createBrowserRouter([
|
|||||||
path: "/account/settings",
|
path: "/account/settings",
|
||||||
element: <AccountSettings />,
|
element: <AccountSettings />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/account/nostr-domain",
|
||||||
|
element: <AccountNostrDomainPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/order",
|
path: "/order",
|
||||||
element: <OrderPage />,
|
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;
|
if (!acc) return;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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">
|
<div className="flex gap-2 items-center">
|
||||||
<h4>Country</h4>
|
<h4>Country</h4>
|
||||||
@ -35,7 +35,11 @@ export function AccountSettings() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="flex gap-2 items-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
@ -5,6 +5,7 @@ import VpsInstanceRow from "../components/vps-instance";
|
|||||||
import { hexToBech32 } from "@snort/shared";
|
import { hexToBech32 } from "@snort/shared";
|
||||||
import { AsyncButton } from "../components/button";
|
import { AsyncButton } from "../components/button";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { AccountNostrDomains } from "../components/account-domains";
|
||||||
|
|
||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
@ -58,6 +59,7 @@ export default function AccountPage() {
|
|||||||
<br />
|
<br />
|
||||||
<b>Please include your public key in all communications.</b>
|
<b>Please include your public key in all communications.</b>
|
||||||
</div>
|
</div>
|
||||||
|
{vms.length > 0 && <h3>VPS</h3>}
|
||||||
{vms.map((a) => (
|
{vms.map((a) => (
|
||||||
<VpsInstanceRow
|
<VpsInstanceRow
|
||||||
key={a.id}
|
key={a.id}
|
||||||
@ -69,6 +71,7 @@ export default function AccountPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<AccountNostrDomains />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user