feat: nostr domain
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-04-03 12:56:55 +01:00
parent 1834242198
commit d2ee1acae7
10 changed files with 324 additions and 13 deletions

View File

@ -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;

View 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>
)}
</>
);
}

View File

@ -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),

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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 />,

View 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"}>&lt; 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>
);
}

View File

@ -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"

View File

@ -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>
);
}