diff --git a/src/api.ts b/src/api.ts index 04e2129..af201fa 100644 --- a/src/api.ts +++ b/src/api.ts @@ -174,6 +174,28 @@ export interface PaymentMethod { metadata?: Record; } +export interface NostrDomainsResponse { + domains: Array; + cname: string; +} + +export interface NostrDomain { + id: number; + name: string; + enabled: boolean; + handles: number; + created: Date; + relays: Array; +} + +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 + >(await this.#req("/api/v1/nostr/domain", "GET")); + return data; + } + + async addDomain(domain: string) { + const { data } = await this.#handleResponse>( + await this.#req("/api/v1/nostr/domain", "POST", { name: domain }), + ); + return data; + } + + async listDomainHandles(id: number) { + const { data } = await this.#handleResponse< + ApiResponse> + >(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>( + 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>( + await this.#req( + `/api/v1/nostr/domain/${domain_id}/handle/${handle_id}`, + "DELETE", + ), + ); + } + async #handleResponse(rsp: Response) { if (rsp.ok) { return (await rsp.json()) as T; diff --git a/src/components/account-domains.tsx b/src/components/account-domains.tsx new file mode 100644 index 0000000..df76097 --- /dev/null +++ b/src/components/account-domains.tsx @@ -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(); + const [addDomain, setAddDomain] = useState(false); + const [newDomain, setNewDomain] = useState(); + + useEffect(() => { + if (login?.api) { + login.api.listDomains().then(setDomains); + } + }, [login]); + + return ( + <> +
+

Nostr Domains

+ + Free NIP-05 hosting, add a CNAME entry pointing to + + {domains?.cname} + + + {domains?.domains.map((d) => ( + + ))} +
+ setAddDomain(true)}>Add Domain + {addDomain && ( + setAddDomain(false)}> +
+
Add Nostr Domain
+ setNewDomain(e.target.value)} + placeholder="example.com" + /> + { + 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 + +
+
+ )} + + ); +} diff --git a/src/components/modal.tsx b/src/components/modal.tsx index 2d7cc2e..ee04786 100644 --- a/src/components/modal.tsx +++ b/src/components/modal.tsx @@ -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), diff --git a/src/components/nostr-domain-row.tsx b/src/components/nostr-domain-row.tsx new file mode 100644 index 0000000..d06f73b --- /dev/null +++ b/src/components/nostr-domain-row.tsx @@ -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 ( +
+
+
{domain.name}
+
+
{domain.handles} handles
+ {!domain.enabled &&
Inactive
} +
+
+ {canEdit && ( + + navigate("/account/nostr-domain", { + state: domain, + }) + } + > + + + )} +
+ ); +} diff --git a/src/components/profile.tsx b/src/components/profile.tsx index a4c80bd..bc6a89a 100644 --- a/src/components/profile.tsx +++ b/src/components/profile.tsx @@ -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" /> -
- {name.length > 0 ? name : hexToBech32("npub", link.id).slice(0, 12)} -
+ {(withName ?? true) && ( +
+ {name.length > 0 ? name : hexToBech32("npub", link.id).slice(0, 12)} +
+ )} ); } diff --git a/src/components/revolut.tsx b/src/components/revolut.tsx index cd975bb..2c3d469 100644 --- a/src/components/revolut.tsx +++ b/src/components/revolut.tsx @@ -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; onPaid: () => void; diff --git a/src/main.tsx b/src/main.tsx index 4c2318e..a15eabd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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: , }, + { + path: "/account/nostr-domain", + element: , + }, { path: "/order", element: , diff --git a/src/pages/account-domain.tsx b/src/pages/account-domain.tsx new file mode 100644 index 0000000..1f27b89 --- /dev/null +++ b/src/pages/account-domain.tsx @@ -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>(); + const [addHandle, setAddHandle] = useState(false); + const [newHandle, setNewHandle] = useState(); + const [newHandlePubkey, setNewHandlePubkey] = useState(); + const [newHandleError, setNewHandleError] = useState(); + const domain = state as NostrDomain; + + useEffect(() => { + if (login?.api) { + login.api.listDomainHandles(domain.id).then(setHandles); + } + }, [login]); + + return ( +
+ < Back + +
Handles
+
+ {handles !== undefined && handles.length === 0 && ( +
No Registerd Handles
+ )} + {handles?.map((a) => ( +
+
+
{a.handle}
+
+ {hexToBech32("npub", a.pubkey)} +
+
+ { + 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); + } + }} + > + + +
+ ))} +
+ setAddHandle(true)}>Add Handle + {addHandle && ( + setAddHandle(false)}> +
+
Add Handle for {domain.name}
+ setNewHandle(e.target.value)} + /> + setNewHandlePubkey(e.target.value)} + /> + {newHandleError && ( +
{newHandleError}
+ )} + { + 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 + +
+
+ )} +
+ ); +} diff --git a/src/pages/account-settings.tsx b/src/pages/account-settings.tsx index 6f3c255..8d5c04f 100644 --- a/src/pages/account-settings.tsx +++ b/src/pages/account-settings.tsx @@ -17,7 +17,7 @@ export function AccountSettings() { if (!acc) return; return (
-

Account Settings

+
Account Settings

Country

@@ -35,7 +35,11 @@ export function AccountSettings() {
-

Notification Settings

+
Notification Settings
+

+ This is only for account notifications such as VM expiration + notifications, we do not send marketing or promotional messages. +

Please include your public key in all communications.
+ {vms.length > 0 &&

VPS

} {vms.map((a) => ( ))} +
); }