From a068b6471ae2a02c34b3a9a2b6d34bced957b82e Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 26 Mar 2026 14:52:52 -0700 Subject: [PATCH] Add hooks --- frontend/spec/lib/hooks.md | 66 +++++++++ frontend/src/components/AppShell.tsx | 8 +- frontend/src/components/RelayDetailCard.tsx | 2 +- frontend/src/lib/hooks.ts | 129 ++++++++++++++++++ frontend/src/pages/Account.tsx | 10 +- frontend/src/pages/admin/AdminRelayDetail.tsx | 10 +- frontend/src/pages/admin/AdminRelayEdit.tsx | 8 +- frontend/src/pages/admin/AdminRelayList.tsx | 6 +- .../src/pages/admin/AdminTenantDetail.tsx | 6 +- frontend/src/pages/admin/AdminTenantList.tsx | 6 +- frontend/src/pages/relays/RelayDetail.tsx | 10 +- frontend/src/pages/relays/RelayEdit.tsx | 8 +- frontend/src/pages/relays/RelayList.tsx | 6 +- frontend/src/pages/relays/RelayNew.tsx | 4 +- 14 files changed, 237 insertions(+), 42 deletions(-) create mode 100644 frontend/spec/lib/hooks.md create mode 100644 frontend/src/lib/hooks.ts diff --git a/frontend/spec/lib/hooks.md b/frontend/spec/lib/hooks.md new file mode 100644 index 0000000..274e648 --- /dev/null +++ b/frontend/spec/lib/hooks.md @@ -0,0 +1,66 @@ +Hooks wrap data access and mutations so pages/components do not call API methods directly. + +## `function useTenant()` + +- Uses `createTenant()` then `getTenant(pubkey)` for the active account +- Returns a Solid `createResource` tuple + +## `function useTenantRelays()` + +- Uses `createTenant()` then `listTenantRelays(pubkey)` for the active account +- Returns a Solid `createResource` tuple + +## `function useTenantInvoices()` + +- Uses `createTenant()` then `listTenantInvoices(pubkey)` for the active account +- Returns a Solid `createResource` tuple + +## `function useRelay(relayId)` + +- Uses `getRelay(id)` +- Returns a Solid `createResource` tuple + +## `function useAdminTenants()` + +- Uses `listTenants()` +- Returns a Solid `createResource` tuple + +## `function useAdminRelays()` + +- Uses `listRelays()` +- Returns a Solid `createResource` tuple + +## `function useAdminTenantDetail(pubkey)` + +- Uses `getTenant(pubkey)` and `listTenantRelays(pubkey)` +- Returns a Solid `createResource` tuple with `{ tenant, relays }` + +## `function useAdminCheck(source)` + +- Uses `listTenants()` as an admin capability check +- Returns `{ is_admin: true }` on success +- Returns `{ is_admin: false }` on `403` + +## `function createRelayForActiveTenant(input)` + +- Uses `createTenant()` then `createRelay({ tenant: activePubkey, ...input })` + +## `function updateActiveTenantBilling(nwc_url)` + +- Uses `updateTenantBilling(activePubkey, { nwc_url })` + +## `function updateRelayById(id, input)` + +- Uses `updateRelay(id, input)` + +## `function updateRelayPlanById(id, plan)` + +- Uses `updateRelay(id, { plan })` + +## `function deactivateRelayById(id)` + +- Uses `deactivateRelay(id)` + +## `function getRelayMemberCount(relayUrl)` + +- Uses relay management (not backend API) to fetch allowed pubkeys and return count diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index c4e5801..0f9a4cf 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,8 +1,8 @@ import { A, useLocation } from "@solidjs/router" -import { createEffect, createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" import Fuse from "fuse.js" -import { adminCheck as fetchAdminCheck, listTenantRelays, type Relay } from "../lib/api" import { eventStore, primeProfiles, useActiveAccount, useProfilePicture } from "../lib/nostr" +import { useAdminCheck, useTenantRelays, type Relay } from "../lib/hooks" import serverIcon from "../assets/server.svg" import Modal from "./Modal" @@ -34,8 +34,8 @@ export default function AppShell(props: { children?: any }) { const location = useLocation() const account = useActiveAccount() const picture = useProfilePicture(() => account()?.pubkey) - const [adminCheck] = createResource(() => account()?.id, () => fetchAdminCheck()) - const [tenantRelays] = createResource(() => account()?.id, () => listTenantRelays()) + const [adminCheck] = useAdminCheck(() => account()?.id) + const [tenantRelays] = useTenantRelays() const [profile, setProfile] = createSignal({}) const [searchOpen, setSearchOpen] = createSignal(false) const [searchQuery, setSearchQuery] = createSignal("") diff --git a/frontend/src/components/RelayDetailCard.tsx b/frontend/src/components/RelayDetailCard.tsx index 30ea4c7..6dd85c1 100644 --- a/frontend/src/components/RelayDetailCard.tsx +++ b/frontend/src/components/RelayDetailCard.tsx @@ -1,6 +1,6 @@ import { A } from "@solidjs/router" import { Show, createEffect, createSignal, onCleanup } from "solid-js" -import type { Relay } from "../lib/api" +import type { Relay } from "../lib/hooks" import menuDotsIcon from "../assets/menu-dots-2.svg" import Modal from "./Modal" import PricingTable from "./PricingTable" diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts new file mode 100644 index 0000000..08518c5 --- /dev/null +++ b/frontend/src/lib/hooks.ts @@ -0,0 +1,129 @@ +import { createResource } from "solid-js" +import { Relay as NostrRelay, RelayManagement } from "applesauce-relay" +import { + ApiError, + createRelay, + createTenant, + deactivateRelay, + getRelay, + getTenant, + listRelays, + listTenantInvoices, + listTenantRelays, + listTenants, + updateRelay, + updateTenantBilling, + type CreateRelayInput, + type Relay, + type Tenant, + type UpdateRelayInput, +} from "./api" +import { getActivePubkey, getActiveSigner } from "./nostr" + +function requireActivePubkey() { + const pubkey = getActivePubkey() + if (!pubkey) throw new Error("Not logged in") + return pubkey +} + +async function ensureActiveTenant() { + try { + await createTenant() + } catch (e) { + if (e instanceof ApiError && e.status === 422) return + throw e + } +} + +export function useTenant() { + return createResource(async () => { + const pubkey = requireActivePubkey() + await ensureActiveTenant() + return getTenant(pubkey) + }) +} + +export function useTenantRelays() { + return createResource(async () => { + const pubkey = requireActivePubkey() + await ensureActiveTenant() + return listTenantRelays(pubkey) + }) +} + +export function useTenantInvoices() { + return createResource(async () => { + const pubkey = requireActivePubkey() + await ensureActiveTenant() + return listTenantInvoices(pubkey) + }) +} + +export function useRelay(relayId: () => string) { + return createResource(relayId, getRelay) +} + +export function useAdminTenants() { + return createResource(listTenants) +} + +export function useAdminRelays() { + return createResource(listRelays) +} + +export function useAdminTenantDetail(pubkey: () => string) { + return createResource(pubkey, async (id) => { + const [tenant, relays] = await Promise.all([getTenant(id), listTenantRelays(id)]) + return { tenant, relays } + }) +} + +export function useAdminCheck(source: () => string | undefined) { + return createResource(source, async () => { + try { + await listTenants() + return { is_admin: true } + } catch (e) { + if (e instanceof ApiError && e.status === 403) return { is_admin: false } + throw e + } + }) +} + +export async function createRelayForActiveTenant(input: CreateRelayInput) { + const pubkey = requireActivePubkey() + await ensureActiveTenant() + return createRelay({ ...input, tenant: pubkey }) +} + +export function updateActiveTenantBilling(nwc_url: string) { + const pubkey = requireActivePubkey() + return updateTenantBilling(pubkey, { nwc_url }) +} + +export function updateRelayById(id: string, input: UpdateRelayInput) { + return updateRelay(id, input) +} + +export function updateRelayPlanById(id: string, plan: string) { + return updateRelay(id, { plan }) +} + +export function deactivateRelayById(id: string) { + return deactivateRelay(id) +} + +export async function getRelayMemberCount(relayUrl: string): Promise { + const signer = getActiveSigner() + if (!signer) throw new Error("Not logged in") + + const management = new RelayManagement(new NostrRelay(relayUrl), signer) + try { + const members = await management.listAllowedPubkeys() + return members.length + } catch { + return undefined + } +} + +export type { Relay, Tenant } diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx index 6743b37..61d7e29 100644 --- a/frontend/src/pages/Account.tsx +++ b/frontend/src/pages/Account.tsx @@ -1,15 +1,15 @@ -import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, Show } from "solid-js" import { useNavigate } from "@solidjs/router" -import { getTenant, listTenantInvoices, updateTenantBilling } from "../lib/api" import PageContainer from "../components/PageContainer" import LoadingState from "../components/LoadingState" import useMinLoading from "../components/useMinLoading" import { accounts, persistAccounts } from "../lib/nostr" +import { updateActiveTenantBilling, useTenant, useTenantInvoices } from "../lib/hooks" export default function Account() { const navigate = useNavigate() - const [tenant, { refetch: refetchTenant }] = createResource(getTenant) - const [invoices] = createResource(listTenantInvoices) + const [tenant, { refetch: refetchTenant }] = useTenant() + const [invoices] = useTenantInvoices() const [nwcUrl, setNwcUrl] = createSignal("") const [saving, setSaving] = createSignal(false) const [error, setError] = createSignal("") @@ -30,7 +30,7 @@ export default function Account() { setSaving(true) try { const next = nwcUrl().trim() - await updateTenantBilling(next) + await updateActiveTenantBilling(next) await refetchTenant() } catch (e) { setError(e instanceof Error ? e.message : "Failed to update billing") diff --git a/frontend/src/pages/admin/AdminRelayDetail.tsx b/frontend/src/pages/admin/AdminRelayDetail.tsx index bf2755c..ed38459 100644 --- a/frontend/src/pages/admin/AdminRelayDetail.tsx +++ b/frontend/src/pages/admin/AdminRelayDetail.tsx @@ -1,16 +1,16 @@ import { useParams } from "@solidjs/router" -import { createResource, createSignal, Show } from "solid-js" -import { adminDeactivateRelay, adminGetRelay, adminUpdateRelay, type Relay } from "../../lib/api" +import { createSignal, Show } from "solid-js" import BackLink from "../../components/BackLink" import PageContainer from "../../components/PageContainer" import RelayDetailCard from "../../components/RelayDetailCard" import ResourceState from "../../components/ResourceState" import useMinLoading from "../../components/useMinLoading" +import { deactivateRelayById, updateRelayById, useRelay, type Relay } from "../../lib/hooks" export default function AdminRelayDetail() { const params = useParams() const relayId = () => params.id ?? "" - const [relay, { refetch, mutate }] = createResource(relayId, adminGetRelay) + const [relay, { refetch, mutate }] = useRelay(relayId) const [busy, setBusy] = createSignal(false) const [error, setError] = createSignal("") const loading = useMinLoading(() => relay.loading && !relay()) @@ -20,7 +20,7 @@ export default function AdminRelayDetail() { setError("") setBusy(true) try { - await adminDeactivateRelay(relayId()) + await deactivateRelayById(relayId()) await refetch() } catch (e) { setError(e instanceof Error ? e.message : "Failed to deactivate relay") @@ -47,7 +47,7 @@ export default function AdminRelayDetail() { mutate(next) try { - await adminUpdateRelay(relayId(), next) + await updateRelayById(relayId(), next) await refetch() } catch (e) { mutate(previous) diff --git a/frontend/src/pages/admin/AdminRelayEdit.tsx b/frontend/src/pages/admin/AdminRelayEdit.tsx index 11b893c..3156689 100644 --- a/frontend/src/pages/admin/AdminRelayEdit.tsx +++ b/frontend/src/pages/admin/AdminRelayEdit.tsx @@ -1,18 +1,18 @@ import { useNavigate, useParams } from "@solidjs/router" -import { Show, createEffect, createResource, createSignal } from "solid-js" -import { adminGetRelay, adminUpdateRelay } from "../../lib/api" +import { Show, createEffect, createSignal } from "solid-js" import RelayForm from "../../components/RelayForm" import { slugify } from "../../lib/slugify" import BackLink from "../../components/BackLink" import PageContainer from "../../components/PageContainer" import ResourceState from "../../components/ResourceState" import useMinLoading from "../../components/useMinLoading" +import { updateRelayById, useRelay } from "../../lib/hooks" export default function AdminRelayEdit() { const navigate = useNavigate() const params = useParams() const relayId = () => params.id ?? "" - const [relay] = createResource(relayId, adminGetRelay) + const [relay] = useRelay(relayId) const loading = useMinLoading(() => relay.loading) const [name, setName] = createSignal("") @@ -36,7 +36,7 @@ export default function AdminRelayEdit() { setError("") setSubmitting(true) try { - await adminUpdateRelay(relayId(), { + await updateRelayById(relayId(), { subdomain: slugify(subdomain()), info_name: name().trim(), info_icon: icon().trim(), diff --git a/frontend/src/pages/admin/AdminRelayList.tsx b/frontend/src/pages/admin/AdminRelayList.tsx index d302042..1b9ed83 100644 --- a/frontend/src/pages/admin/AdminRelayList.tsx +++ b/frontend/src/pages/admin/AdminRelayList.tsx @@ -1,14 +1,14 @@ import { A } from "@solidjs/router" import Fuse from "fuse.js" -import { createMemo, createResource, createSignal, For, Show } from "solid-js" -import { adminListRelays } from "../../lib/api" +import { createMemo, createSignal, For, Show } from "solid-js" import PageContainer from "../../components/PageContainer" import ResourceState from "../../components/ResourceState" import useMinLoading from "../../components/useMinLoading" +import { useAdminRelays } from "../../lib/hooks" export default function AdminRelayList() { const [query, setQuery] = createSignal("") - const [relays] = createResource(adminListRelays) + const [relays] = useAdminRelays() const loading = useMinLoading(() => relays.loading) const filtered = createMemo(() => { diff --git a/frontend/src/pages/admin/AdminTenantDetail.tsx b/frontend/src/pages/admin/AdminTenantDetail.tsx index bbfba3f..7bfc808 100644 --- a/frontend/src/pages/admin/AdminTenantDetail.tsx +++ b/frontend/src/pages/admin/AdminTenantDetail.tsx @@ -1,15 +1,15 @@ import { useParams, A } from "@solidjs/router" -import { createResource, For, Show } from "solid-js" -import { adminGetTenant } from "../../lib/api" +import { For, Show } from "solid-js" import BackLink from "../../components/BackLink" import PageContainer from "../../components/PageContainer" import ResourceState from "../../components/ResourceState" import useMinLoading from "../../components/useMinLoading" +import { useAdminTenantDetail } from "../../lib/hooks" export default function AdminTenantDetail() { const params = useParams() const tenantId = () => params.id ?? "" - const [detail] = createResource(tenantId, adminGetTenant) + const [detail] = useAdminTenantDetail(tenantId) const loading = useMinLoading(() => detail.loading) return ( diff --git a/frontend/src/pages/admin/AdminTenantList.tsx b/frontend/src/pages/admin/AdminTenantList.tsx index 3758448..a63448a 100644 --- a/frontend/src/pages/admin/AdminTenantList.tsx +++ b/frontend/src/pages/admin/AdminTenantList.tsx @@ -1,12 +1,12 @@ import { A } from "@solidjs/router" import Fuse from "fuse.js" -import { createEffect, createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" import { getProfilePicture } from "applesauce-core/helpers/profile" -import { adminListTenants } from "../../lib/api" import { eventStore, primeProfiles } from "../../lib/nostr" import PageContainer from "../../components/PageContainer" import ResourceState from "../../components/ResourceState" import useMinLoading from "../../components/useMinLoading" +import { useAdminTenants } from "../../lib/hooks" function shortenPubkey(pubkey: string) { if (pubkey.length <= 16) return pubkey @@ -15,7 +15,7 @@ function shortenPubkey(pubkey: string) { export default function AdminTenantList() { const [query, setQuery] = createSignal("") - const [tenants] = createResource(adminListTenants) + const [tenants] = useAdminTenants() const [profiles, setProfiles] = createSignal>({}) const loading = useMinLoading(() => tenants.loading) diff --git a/frontend/src/pages/relays/RelayDetail.tsx b/frontend/src/pages/relays/RelayDetail.tsx index 54678a5..30431dc 100644 --- a/frontend/src/pages/relays/RelayDetail.tsx +++ b/frontend/src/pages/relays/RelayDetail.tsx @@ -1,17 +1,17 @@ import { useParams } from "@solidjs/router" import { createMemo, createResource, createSignal, Show } from "solid-js" -import { deactivateTenantRelay, getRelayMemberCount, getTenantRelay, updateTenantRelay, updateTenantRelayPlan, type Relay } from "../../lib/api" import type { RelayPlanId } from "../../lib/relayPlans" import BackLink from "../../components/BackLink" import PageContainer from "../../components/PageContainer" import RelayDetailCard from "../../components/RelayDetailCard" import ResourceState from "../../components/ResourceState" import useMinLoading from "../../components/useMinLoading" +import { deactivateRelayById, getRelayMemberCount, updateRelayById, updateRelayPlanById, useRelay, type Relay } from "../../lib/hooks" export default function RelayDetail() { const params = useParams() const relayId = () => params.id ?? "" - const [relay, { refetch, mutate }] = createResource(relayId, getTenantRelay) + const [relay, { refetch, mutate }] = useRelay(relayId) const relayUrl = createMemo(() => { const subdomain = relay()?.subdomain return subdomain ? `wss://${subdomain}.spaces.coracle.social` : undefined @@ -26,7 +26,7 @@ export default function RelayDetail() { setError("") setBusy(true) try { - await deactivateTenantRelay(relayId()) + await deactivateRelayById(relayId()) await refetch() } catch (e) { setError(e instanceof Error ? e.message : "Failed to deactivate relay") @@ -53,7 +53,7 @@ export default function RelayDetail() { mutate(next) try { - await updateTenantRelay(relayId(), next) + await updateRelayById(relayId(), next) await refetch() } catch (e) { mutate(previous) @@ -148,7 +148,7 @@ export default function RelayDetail() { mutate(next) try { - await updateTenantRelayPlan(relayId(), plan) + await updateRelayPlanById(relayId(), plan) await refetch() } catch (e) { mutate(previous) diff --git a/frontend/src/pages/relays/RelayEdit.tsx b/frontend/src/pages/relays/RelayEdit.tsx index 80542dc..96f8d8f 100644 --- a/frontend/src/pages/relays/RelayEdit.tsx +++ b/frontend/src/pages/relays/RelayEdit.tsx @@ -1,18 +1,18 @@ import { useNavigate, useParams } from "@solidjs/router" -import { Show, createEffect, createResource, createSignal } from "solid-js" -import { getTenantRelay, updateTenantRelay } from "../../lib/api" +import { Show, createEffect, createSignal } from "solid-js" import RelayForm from "../../components/RelayForm" import { slugify } from "../../lib/slugify" import BackLink from "../../components/BackLink" import PageContainer from "../../components/PageContainer" import ResourceState from "../../components/ResourceState" import useMinLoading from "../../components/useMinLoading" +import { updateRelayById, useRelay } from "../../lib/hooks" export default function RelayEdit() { const navigate = useNavigate() const params = useParams() const relayId = () => params.id ?? "" - const [relay] = createResource(relayId, getTenantRelay) + const [relay] = useRelay(relayId) const loading = useMinLoading(() => relay.loading) const [name, setName] = createSignal("") @@ -36,7 +36,7 @@ export default function RelayEdit() { setError("") setSubmitting(true) try { - await updateTenantRelay(relayId(), { + await updateRelayById(relayId(), { subdomain: slugify(subdomain()), info_name: name().trim(), info_icon: icon().trim(), diff --git a/frontend/src/pages/relays/RelayList.tsx b/frontend/src/pages/relays/RelayList.tsx index 137d27b..3ece2f4 100644 --- a/frontend/src/pages/relays/RelayList.tsx +++ b/frontend/src/pages/relays/RelayList.tsx @@ -1,13 +1,13 @@ import { A } from "@solidjs/router" import Fuse from "fuse.js" -import { createMemo, createResource, createSignal, For, Show } from "solid-js" -import { listTenantRelays } from "../../lib/api" +import { createMemo, createSignal, For, Show } from "solid-js" import PageContainer from "../../components/PageContainer" import ResourceState from "../../components/ResourceState" import useMinLoading from "../../components/useMinLoading" +import { useTenantRelays } from "../../lib/hooks" export default function RelayList() { - const [relays] = createResource(listTenantRelays) + const [relays] = useTenantRelays() const [query, setQuery] = createSignal("") const [status, setStatus] = createSignal("all") const loading = useMinLoading(() => relays.loading) diff --git a/frontend/src/pages/relays/RelayNew.tsx b/frontend/src/pages/relays/RelayNew.tsx index ce8e3ee..6fe06b5 100644 --- a/frontend/src/pages/relays/RelayNew.tsx +++ b/frontend/src/pages/relays/RelayNew.tsx @@ -1,7 +1,7 @@ import { Show, createSignal } from "solid-js" import { useNavigate } from "@solidjs/router" -import { createTenantRelay } from "../../lib/api" import { slugify } from "../../lib/slugify" +import { createRelayForActiveTenant } from "../../lib/hooks" const PLANS = [ { id: "free", label: "Free", price: 0, members: "Up to 10", blossom: false, livekit: false }, @@ -34,7 +34,7 @@ export default function RelayNew() { setSubmitting(true) try { - const relay = await createTenantRelay({ + const relay = await createRelayForActiveTenant({ subdomain: slugify(subdomain()), plan: plan(), info_name: name().trim(),