diff --git a/.fdignore b/.fdignore
index 47f39b2..d3cda6f 100644
--- a/.fdignore
+++ b/.fdignore
@@ -1,3 +1,4 @@
ref
target
.agents
+.playwright-cli
diff --git a/frontend/bun.lock b/frontend/bun.lock
index 3d14afe..af2f34e 100644
--- a/frontend/bun.lock
+++ b/frontend/bun.lock
@@ -17,6 +17,7 @@
"applesauce-relay": "^5.1.0",
"applesauce-signers": "^5.1.0",
"applesauce-wallet-connect": "^5.0.1",
+ "fuse.js": "^7.1.0",
"preline": "^4.1.1",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
@@ -348,6 +349,8 @@
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+ "fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
+
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
diff --git a/frontend/package.json b/frontend/package.json
index 75ea692..cf1b044 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -21,6 +21,7 @@
"applesauce-relay": "^5.1.0",
"applesauce-signers": "^5.1.0",
"applesauce-wallet-connect": "^5.0.1",
+ "fuse.js": "^7.1.0",
"preline": "^4.1.1",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index be1f655..9020f9a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,7 +1,7 @@
-import { createEffect, createResource, Show } from "solid-js"
+import { createEffect, Show } from "solid-js"
import { Router, Route, useLocation, useNavigate } from "@solidjs/router"
import type { Component } from "solid-js"
-import Navbar from "./components/Navbar"
+import AppShell from "./components/AppShell"
import Home from "./pages/Home"
import Login from "./pages/Login"
import RelayList from "./pages/relays/RelayList"
@@ -9,16 +9,21 @@ import RelayNew from "./pages/relays/RelayNew"
import RelayDetail from "./pages/relays/RelayDetail"
import RelayEdit from "./pages/relays/RelayEdit"
import Account from "./pages/Account"
-import AdminTenants from "./pages/admin/AdminTenants"
+import AdminTenantList from "./pages/admin/AdminTenantList"
import AdminTenantDetail from "./pages/admin/AdminTenantDetail"
-import AdminRelays from "./pages/admin/AdminRelays"
+import AdminRelayList from "./pages/admin/AdminRelayList"
import AdminRelayDetail from "./pages/admin/AdminRelayDetail"
import AdminRelayEdit from "./pages/admin/AdminRelayEdit"
import { useActiveAccount } from "./lib/nostr"
-import { adminCheck as fetchAdminCheck } from "./lib/api"
function Layout(props: { children?: any }) {
const location = useLocation()
+ const account = useActiveAccount()
+
+ const usesAppShell = () => {
+ const path = location.pathname
+ return path.startsWith("/relays") || path.startsWith("/account") || path.startsWith("/admin")
+ }
createEffect(() => {
// Reinitialize Preline components on route change
@@ -28,8 +33,9 @@ function Layout(props: { children?: any }) {
return (
-
-
{props.children}
+
{props.children}}>
+ {props.children}
+
)
}
@@ -52,10 +58,6 @@ export default function App() {
return () => {
const navigate = useNavigate()
const account = useActiveAccount()
- const [adminCheck] = createResource(
- () => account()?.id,
- () => fetchAdminCheck(),
- )
createEffect(() => {
if (!account()) navigate("/login", { replace: true })
@@ -63,14 +65,7 @@ export default function App() {
return (
- Checking admin access...}>
- You do not have admin access.}
- >
-
-
-
+
)
}
@@ -85,9 +80,9 @@ export default function App() {
-
+
-
+
diff --git a/frontend/src/assets/server.svg b/frontend/src/assets/server.svg
new file mode 100644
index 0000000..e5954c3
--- /dev/null
+++ b/frontend/src/assets/server.svg
@@ -0,0 +1,8 @@
+
diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx
new file mode 100644
index 0000000..a65c103
--- /dev/null
+++ b/frontend/src/components/AppShell.tsx
@@ -0,0 +1,253 @@
+import { A, useLocation } from "@solidjs/router"
+import { createEffect, createMemo, createResource, 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 serverIcon from "../assets/server.svg"
+
+type Profile = {
+ name?: string
+ display_name?: string
+ nip05?: string
+}
+
+function shortenPubkey(pubkey?: string) {
+ if (!pubkey) return ""
+ return `${pubkey.slice(0, 8)}…${pubkey.slice(-6)}`
+}
+
+function SearchIcon() {
+ return (
+
+ )
+}
+
+function RelayIcon() {
+ return
+}
+
+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 [profile, setProfile] = createSignal({})
+ const [searchOpen, setSearchOpen] = createSignal(false)
+ const [searchEntered, setSearchEntered] = createSignal(false)
+ const [searchQuery, setSearchQuery] = createSignal("")
+ let searchTransitionTimer: number | undefined
+
+ const isAdmin = createMemo(() => !!adminCheck()?.is_admin)
+ const username = createMemo(() => profile().name || profile().display_name || shortenPubkey(account()?.pubkey))
+ const nip05 = createMemo(() => profile().nip05 || "No NIP-05")
+ const searchedRelays = createMemo(() => {
+ const list = tenantRelays() ?? []
+ const query = searchQuery().trim()
+
+ if (!query) return list
+
+ const fuse = new Fuse(list, {
+ keys: ["name", "subdomain"],
+ threshold: 0.35,
+ ignoreLocation: true,
+ })
+
+ return fuse.search(query).map((result) => result.item)
+ })
+
+ createEffect(() => {
+ const pubkey = account()?.pubkey
+
+ if (!pubkey) {
+ setProfile({})
+ return
+ }
+
+ const profileSub = eventStore.profile(pubkey).subscribe((metadata) => {
+ setProfile({
+ name: metadata?.name,
+ display_name: metadata?.display_name,
+ nip05: metadata?.nip05,
+ })
+ })
+
+ const primeSub = primeProfiles([pubkey])
+
+ onCleanup(() => {
+ profileSub.unsubscribe()
+ primeSub.unsubscribe()
+ })
+ })
+
+ const myResources = [{ href: "/relays", label: "My Relays" }]
+ const adminResources = [
+ { href: "/admin/tenants", label: "Tenants" },
+ { href: "/admin/relays", label: "Relays" },
+ ]
+
+ const navItemClass = (href: string) => {
+ const active = location.pathname === href || location.pathname.startsWith(`${href}/`)
+ return active
+ ? "block rounded-lg bg-white/15 px-3 py-2 text-sm font-medium text-white"
+ : "block rounded-lg px-3 py-2 text-sm text-white/80 hover:bg-white/10 hover:text-white"
+ }
+
+ const openSearchModal = () => {
+ if (searchTransitionTimer) window.clearTimeout(searchTransitionTimer)
+ setSearchOpen(true)
+ setSearchEntered(false)
+ window.requestAnimationFrame(() => setSearchEntered(true))
+ }
+
+ const closeSearchModal = () => {
+ setSearchEntered(false)
+ searchTransitionTimer = window.setTimeout(() => {
+ setSearchOpen(false)
+ setSearchQuery("")
+ }, 200)
+ }
+
+ onCleanup(() => {
+ if (searchTransitionTimer) window.clearTimeout(searchTransitionTimer)
+ })
+
+ return (
+
+
+
+
+
+ {props.children}
+
+
+
+
+
+
+
e.stopPropagation()}
+ >
+
+
+
+
+
+
+
setSearchQuery(e.currentTarget.value)}
+ placeholder="Search your relays"
+ class="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3"
+ autofocus
+ />
+
+
+
+
+
+
+
Loading relays...}>
+ 0} fallback={No relays found.
}>
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/LoadingState.tsx b/frontend/src/components/LoadingState.tsx
new file mode 100644
index 0000000..14b7b5f
--- /dev/null
+++ b/frontend/src/components/LoadingState.tsx
@@ -0,0 +1,15 @@
+type LoadingStateProps = {
+ message: string
+ class?: string
+ paddingClass?: string
+}
+
+export default function LoadingState(props: LoadingStateProps) {
+ const paddingClass = () => props.paddingClass ?? "py-20"
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ResourceState.tsx b/frontend/src/components/ResourceState.tsx
index a496366..968d8ad 100644
--- a/frontend/src/components/ResourceState.tsx
+++ b/frontend/src/components/ResourceState.tsx
@@ -1,4 +1,5 @@
import { Show } from "solid-js"
+import LoadingState from "./LoadingState"
type ResourceStateProps = {
loading: boolean
@@ -12,10 +13,10 @@ export default function ResourceState(props: ResourceStateProps) {
return (
<>
- {props.loadingText}
+
- {props.errorText}
+ {props.errorText}
>
)
diff --git a/frontend/src/components/useMinLoading.ts b/frontend/src/components/useMinLoading.ts
new file mode 100644
index 0000000..518c37a
--- /dev/null
+++ b/frontend/src/components/useMinLoading.ts
@@ -0,0 +1,41 @@
+import { createEffect, createSignal, onCleanup } from "solid-js"
+
+export default function useMinLoading(loading: () => boolean, minDurationMs = 200) {
+ const [visible, setVisible] = createSignal(false)
+ let startTime = 0
+ let timeout: number | undefined
+
+ createEffect(() => {
+ if (timeout) {
+ window.clearTimeout(timeout)
+ timeout = undefined
+ }
+
+ if (loading()) {
+ startTime = Date.now()
+ setVisible(true)
+ return
+ }
+
+ if (!visible()) return
+
+ const elapsed = Date.now() - startTime
+ const remaining = Math.max(minDurationMs - elapsed, 0)
+
+ if (remaining === 0) {
+ setVisible(false)
+ return
+ }
+
+ timeout = window.setTimeout(() => {
+ setVisible(false)
+ timeout = undefined
+ }, remaining)
+
+ onCleanup(() => {
+ if (timeout) window.clearTimeout(timeout)
+ })
+ })
+
+ return visible
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index e4b2610..97b37b1 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -74,6 +74,9 @@ async function request(path: string, init?: RequestInit): Promise {
} catch {
// ignored
}
+ if (response.status === 403 && path.startsWith("/admin/") && typeof window !== "undefined") {
+ window.location.replace("/relays")
+ }
throw new ApiError(message, response.status)
}
diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx
index 1c7f503..106616f 100644
--- a/frontend/src/pages/Account.tsx
+++ b/frontend/src/pages/Account.tsx
@@ -1,7 +1,8 @@
-import { createMemo, createResource, createSignal, For, Show } from "solid-js"
+import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"
import { getTenant, listTenantInvoices, updateTenantBilling } from "../lib/api"
import PageContainer from "../components/PageContainer"
-import ResourceState from "../components/ResourceState"
+import LoadingState from "../components/LoadingState"
+import useMinLoading from "../components/useMinLoading"
export default function Account() {
const [tenant, { refetch: refetchTenant }] = createResource(getTenant)
@@ -9,15 +10,24 @@ export default function Account() {
const [nwcUrl, setNwcUrl] = createSignal("")
const [saving, setSaving] = createSignal(false)
const [error, setError] = createSignal("")
+ const invoicesLoading = useMinLoading(() => invoices.loading)
- const recurringEnabled = createMemo(() => !!tenant()?.tenant_nwc_url?.trim())
+ const hasBillingChanges = createMemo(() => {
+ const current = tenant()?.tenant_nwc_url?.trim() ?? ""
+ const next = nwcUrl().trim()
+ return current !== next
+ })
+
+ createEffect(() => {
+ setNwcUrl(tenant()?.tenant_nwc_url ?? "")
+ })
async function saveBilling() {
setError("")
setSaving(true)
try {
- await updateTenantBilling(nwcUrl().trim())
- setNwcUrl("")
+ const next = nwcUrl().trim()
+ await updateTenantBilling(next)
await refetchTenant()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update billing")
@@ -28,7 +38,7 @@ export default function Account() {
return (
- My Account
+ My Account
@@ -42,12 +52,6 @@ export default function Account() {
)}
-
@@ -66,22 +70,11 @@ export default function Account() {
-
{error()}
@@ -90,24 +83,26 @@ export default function Account() {
Invoice History
-
- Loading invoices...
+
+
- 0} fallback={No invoices yet.
}>
-
-
- {(invoice) => (
- -
-
- {invoice.amount.toLocaleString()} sats
- {invoice.status}
-
- {new Date(invoice.created_at).toLocaleString()}
- {invoice.invoice}
-
- )}
-
-
+
+ 0} fallback={No invoices yet.
}>
+
+
+ {(invoice) => (
+ -
+
+ {invoice.amount.toLocaleString()} sats
+ {invoice.status}
+
+ {new Date(invoice.created_at).toLocaleString()}
+ {invoice.invoice}
+
+ )}
+
+
+
diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx
index 6e0d73e..eda24ac 100644
--- a/frontend/src/pages/Home.tsx
+++ b/frontend/src/pages/Home.tsx
@@ -5,7 +5,7 @@ export default function Home() {
{/* Hero */}
-
+
Host Your Own Nostr Community Relay
@@ -22,7 +22,7 @@ export default function Home() {
{/* Pricing */}
-
Pricing
+
Pricing
Free
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index 0288168..5e5b5a2 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -206,7 +206,7 @@ export default function Login() {
Secure Nostr Login
-
Welcome back
+
Welcome back
Connect your Nostr account to manage relay hosting, billing, and access in one place.
diff --git a/frontend/src/pages/admin/AdminRelayDetail.tsx b/frontend/src/pages/admin/AdminRelayDetail.tsx
index 1df0432..9089ef9 100644
--- a/frontend/src/pages/admin/AdminRelayDetail.tsx
+++ b/frontend/src/pages/admin/AdminRelayDetail.tsx
@@ -4,6 +4,7 @@ import { adminDeactivateRelay, adminGetRelay } from "../../lib/api"
import BackLink from "../../components/BackLink"
import PageContainer from "../../components/PageContainer"
import ResourceState from "../../components/ResourceState"
+import useMinLoading from "../../components/useMinLoading"
export default function AdminRelayDetail() {
const params = useParams()
@@ -11,6 +12,7 @@ export default function AdminRelayDetail() {
const [relay, { refetch }] = createResource(relayId, adminGetRelay)
const [busy, setBusy] = createSignal(false)
const [error, setError] = createSignal("")
+ const loading = useMinLoading(() => relay.loading)
async function handleDeactivate() {
if (busy()) return
@@ -31,17 +33,17 @@ export default function AdminRelayDetail() {
-
+
{(r) => (
-
{r().name}
+
{r().name}
{r().subdomain}.spaces.coracle.social
Tenant: {r().tenant}
Plan: {r().plan}
diff --git a/frontend/src/pages/admin/AdminRelayEdit.tsx b/frontend/src/pages/admin/AdminRelayEdit.tsx
index af5f41f..2f36acf 100644
--- a/frontend/src/pages/admin/AdminRelayEdit.tsx
+++ b/frontend/src/pages/admin/AdminRelayEdit.tsx
@@ -7,12 +7,14 @@ 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"
export default function AdminRelayEdit() {
const navigate = useNavigate()
const params = useParams()
const relayId = () => params.id ?? ""
const [relay] = createResource(relayId, adminGetRelay)
+ const loading = useMinLoading(() => relay.loading)
const [name, setName] = createSignal("")
const [subdomain, setSubdomain] = createSignal("")
@@ -55,16 +57,16 @@ export default function AdminRelayEdit() {
return (
- Edit Relay (Admin)
+ Edit Relay (Admin)
-
+
relays.loading)
+
+ const filtered = createMemo(() => {
+ const list = relays() ?? []
+ const q = query().trim()
+
+ if (!q) return list
+
+ return new Fuse(list, {
+ keys: ["name", "subdomain", "tenant"],
+ threshold: 0.35,
+ ignoreLocation: true,
+ }).search(q).map((result) => result.item)
+ })
+
+ return (
+
+
+
Relays
+
+
+
+
+
+
setQuery(e.currentTarget.value)}
+ placeholder="Search relays..."
+ class="w-full border border-gray-300 rounded-lg py-2 pl-10 pr-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+
+
+ 0} fallback={No relays found.
}>
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/admin/AdminRelays.tsx b/frontend/src/pages/admin/AdminRelays.tsx
deleted file mode 100644
index 4c6c209..0000000
--- a/frontend/src/pages/admin/AdminRelays.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { A } from "@solidjs/router"
-import { createMemo, createResource, createSignal, For, Show } from "solid-js"
-import { adminListRelays } from "../../lib/api"
-import PageContainer from "../../components/PageContainer"
-import ResourceState from "../../components/ResourceState"
-
-export default function AdminRelays() {
- const [query, setQuery] = createSignal("")
- const [relays] = createResource(adminListRelays)
-
- const filtered = createMemo(() => {
- const q = query().trim().toLowerCase()
- return (relays() ?? []).filter((relay) => {
- if (!q) return true
- return (
- relay.name.toLowerCase().includes(q)
- || relay.subdomain.toLowerCase().includes(q)
- || relay.tenant.toLowerCase().includes(q)
- )
- })
- })
-
- return (
-
- All Relays
- setQuery(e.currentTarget.value)}
- placeholder="Search relays..."
- class="w-full border border-gray-300 rounded-lg px-3 py-2 mb-6 focus:outline-none focus:ring-2 focus:ring-blue-500"
- />
-
-
- 0} fallback={No relays found.
}>
-
-
-
- )
-}
diff --git a/frontend/src/pages/admin/AdminTenantDetail.tsx b/frontend/src/pages/admin/AdminTenantDetail.tsx
index 13c77ad..5f22023 100644
--- a/frontend/src/pages/admin/AdminTenantDetail.tsx
+++ b/frontend/src/pages/admin/AdminTenantDetail.tsx
@@ -4,6 +4,7 @@ import { adminGetTenant, adminUpdateTenantStatus } from "../../lib/api"
import BackLink from "../../components/BackLink"
import PageContainer from "../../components/PageContainer"
import ResourceState from "../../components/ResourceState"
+import useMinLoading from "../../components/useMinLoading"
export default function AdminTenantDetail() {
const params = useParams()
@@ -11,6 +12,7 @@ export default function AdminTenantDetail() {
const [detail, { refetch }] = createResource(tenantId, adminGetTenant)
const [busy, setBusy] = createSignal(false)
const [error, setError] = createSignal("")
+ const loading = useMinLoading(() => detail.loading)
async function setStatus(status: string) {
if (busy()) return
@@ -29,72 +31,74 @@ export default function AdminTenantDetail() {
return (
- Tenant {params.id}
+ Tenant {params.id}
-
-
- Status
-
- {(d) => (
-
-
- Current: {d().tenant.status}
-
-
-
-
+
+
+
+ Status
+
+ {(d) => (
+
+
+ Current: {d().tenant.status}
+
+
+
+
+
-
- )}
-
-
+ )}
+
+
-
- Relays
- 0} fallback={No relays.
}>
-
+
)
}
diff --git a/frontend/src/pages/admin/AdminTenantList.tsx b/frontend/src/pages/admin/AdminTenantList.tsx
new file mode 100644
index 0000000..b64298d
--- /dev/null
+++ b/frontend/src/pages/admin/AdminTenantList.tsx
@@ -0,0 +1,130 @@
+import { A } from "@solidjs/router"
+import Fuse from "fuse.js"
+import { createEffect, createMemo, createResource, 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"
+
+function shortenPubkey(pubkey: string) {
+ if (pubkey.length <= 16) return pubkey
+ return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
+}
+
+export default function AdminTenantList() {
+ const [query, setQuery] = createSignal("")
+ const [tenants] = createResource(adminListTenants)
+ const [profiles, setProfiles] = createSignal
>({})
+ const loading = useMinLoading(() => tenants.loading)
+
+ const filtered = createMemo(() => {
+ const list = (tenants() ?? []).map((tenant) => {
+ const profile = profiles()[tenant.pubkey]
+ return {
+ ...tenant,
+ profileName: profile?.name,
+ profileAbout: profile?.about,
+ profileNip05: profile?.nip05,
+ }
+ })
+ const q = query().trim()
+
+ if (!q) return list
+
+ return new Fuse(list, {
+ keys: ["pubkey", "status", "profileName", "profileAbout", "profileNip05"],
+ threshold: 0.35,
+ ignoreLocation: true,
+ }).search(q).map((result) => result.item)
+ })
+
+ createEffect(() => {
+ const list = tenants() ?? []
+ if (!list.length) return
+
+ const pubkeys = list.map(t => t.pubkey)
+ const reqSub = primeProfiles(pubkeys)
+ const profileSubs = pubkeys.map((pubkey) =>
+ eventStore.profile(pubkey).subscribe((profile) => {
+ setProfiles(prev => ({
+ ...prev,
+ [pubkey]: {
+ name: profile?.name || profile?.display_name,
+ about: profile?.about,
+ nip05: profile?.nip05,
+ picture: getProfilePicture(profile),
+ },
+ }))
+ }),
+ )
+
+ onCleanup(() => {
+ reqSub.unsubscribe()
+ for (const sub of profileSubs) sub.unsubscribe()
+ })
+ })
+
+ return (
+
+ Tenants
+
+
+
+
+
setQuery(e.currentTarget.value)}
+ placeholder="Search tenants..."
+ class="w-full border border-gray-300 rounded-lg py-2 pl-10 pr-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+
+
+ 0} fallback={No tenants found.
}>
+
+
+
+ )
+ }}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/admin/AdminTenants.tsx b/frontend/src/pages/admin/AdminTenants.tsx
deleted file mode 100644
index 4ae5b77..0000000
--- a/frontend/src/pages/admin/AdminTenants.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import { A } from "@solidjs/router"
-import { createEffect, createMemo, createResource, 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"
-
-function shortenPubkey(pubkey: string) {
- if (pubkey.length <= 16) return pubkey
- return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
-}
-
-export default function AdminTenants() {
- const [query, setQuery] = createSignal("")
- const [tenants] = createResource(adminListTenants)
- const [profiles, setProfiles] = createSignal>({})
-
- const filtered = createMemo(() => {
- const q = query().trim().toLowerCase()
- return (tenants() ?? []).filter((tenant) => {
- if (!q) return true
- return tenant.pubkey.toLowerCase().includes(q) || tenant.status.toLowerCase().includes(q)
- })
- })
-
- createEffect(() => {
- const list = tenants() ?? []
- if (!list.length) return
-
- const pubkeys = list.map(t => t.pubkey)
- const reqSub = primeProfiles(pubkeys)
- const profileSubs = pubkeys.map((pubkey) =>
- eventStore.profile(pubkey).subscribe((profile) => {
- setProfiles(prev => ({
- ...prev,
- [pubkey]: {
- name: profile?.name || profile?.display_name,
- about: profile?.about,
- picture: getProfilePicture(profile),
- },
- }))
- }),
- )
-
- onCleanup(() => {
- reqSub.unsubscribe()
- for (const sub of profileSubs) sub.unsubscribe()
- })
- })
-
- return (
-
- Tenants
- setQuery(e.currentTarget.value)}
- placeholder="Search tenants..."
- class="w-full border border-gray-300 rounded-lg px-3 py-2 mb-6 focus:outline-none focus:ring-2 focus:ring-blue-500"
- />
-
-
- 0} fallback={No tenants found.
}>
-
-
-
- )
- }}
-
-
-
-
- )
-}
diff --git a/frontend/src/pages/relays/RelayDetail.tsx b/frontend/src/pages/relays/RelayDetail.tsx
index 53823b1..0178e65 100644
--- a/frontend/src/pages/relays/RelayDetail.tsx
+++ b/frontend/src/pages/relays/RelayDetail.tsx
@@ -4,6 +4,7 @@ import { deactivateTenantRelay, getTenantRelay } from "../../lib/api"
import BackLink from "../../components/BackLink"
import PageContainer from "../../components/PageContainer"
import ResourceState from "../../components/ResourceState"
+import useMinLoading from "../../components/useMinLoading"
export default function RelayDetail() {
const params = useParams()
@@ -11,6 +12,7 @@ export default function RelayDetail() {
const [relay, { refetch }] = createResource(relayId, getTenantRelay)
const [busy, setBusy] = createSignal(false)
const [error, setError] = createSignal("")
+ const loading = useMinLoading(() => relay.loading)
async function handleDeactivate() {
if (busy()) return
@@ -31,17 +33,17 @@ export default function RelayDetail() {
-
+
{(loadedRelay) => (
-
{loadedRelay().name}
+
{loadedRelay().name}
https://{loadedRelay().subdomain}.spaces.coracle.social
{loadedRelay().description}
diff --git a/frontend/src/pages/relays/RelayEdit.tsx b/frontend/src/pages/relays/RelayEdit.tsx
index 847f2bc..b9b696c 100644
--- a/frontend/src/pages/relays/RelayEdit.tsx
+++ b/frontend/src/pages/relays/RelayEdit.tsx
@@ -7,12 +7,14 @@ 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"
export default function RelayEdit() {
const navigate = useNavigate()
const params = useParams()
const relayId = () => params.id ?? ""
const [relay] = createResource(relayId, getTenantRelay)
+ const loading = useMinLoading(() => relay.loading)
const [name, setName] = createSignal("")
const [subdomain, setSubdomain] = createSignal("")
@@ -55,16 +57,16 @@ export default function RelayEdit() {
return (
- Edit Relay
+ Edit Relay
-
+
relays.loading)
const filtered = createMemo(() => {
- const q = query().trim().toLowerCase()
- return (relays() ?? []).filter((relay) => {
- const matchesQuery =
- !q ||
- relay.name.toLowerCase().includes(q) ||
- relay.subdomain.toLowerCase().includes(q)
+ const list = relays() ?? []
+ const q = query().trim()
+ const searched = q
+ ? new Fuse(list, {
+ keys: ["name", "subdomain"],
+ threshold: 0.35,
+ ignoreLocation: true,
+ }).search(q).map((result) => result.item)
+ : list
+
+ return searched.filter((relay) => {
const matchesStatus = status() === "all" || relay.status === status()
- return matchesQuery && matchesStatus
+ return matchesStatus
})
})
return (