forked from coracle/caravel
Show tenant links, remove plan from relay edit form
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { A } from "@solidjs/router"
|
||||
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { getProfilePicture } from "applesauce-core/helpers/profile"
|
||||
import type { Relay, PlanId } from "@/lib/api"
|
||||
import menuDotsIcon from "@/assets/menu-dots-2.svg"
|
||||
import ConfirmDialog from "@/components/ConfirmDialog"
|
||||
@@ -8,7 +9,12 @@ import PricingTable from "@/components/PricingTable"
|
||||
import ToggleButton from "@/components/ToggleButton"
|
||||
import ToggleField from "@/components/ToggleField"
|
||||
import { setToastMessage } from "@/components/Toast"
|
||||
import { plans } from "@/lib/state"
|
||||
import { primeProfiles } from "@/lib/hooks"
|
||||
import { eventStore, plans } from "@/lib/state"
|
||||
|
||||
function shortenPubkey(pubkey: string) {
|
||||
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
active: "bg-green-50 text-green-700 border-green-200",
|
||||
@@ -78,6 +84,28 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||
const [menuOpen, setMenuOpen] = createSignal(false)
|
||||
const [planId, setPlanId] = createSignal<PlanId>(props.relay.plan_id)
|
||||
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
|
||||
const [tenantProfile, setTenantProfile] = createSignal<{ name?: string; picture?: string }>({})
|
||||
|
||||
// Resolve the owning tenant's profile so the Tenant field can show a name and
|
||||
// avatar instead of a raw pubkey. Only relevant in admin (showTenant) views.
|
||||
createEffect(() => {
|
||||
if (!props.showTenant) return
|
||||
const pubkey = props.relay.tenant_pubkey
|
||||
if (!pubkey) return
|
||||
|
||||
const sub = eventStore.profile(pubkey).subscribe((metadata) => {
|
||||
setTenantProfile({
|
||||
name: metadata?.name || metadata?.display_name,
|
||||
picture: getProfilePicture(metadata),
|
||||
})
|
||||
})
|
||||
const primeSub = primeProfiles([pubkey])
|
||||
|
||||
onCleanup(() => {
|
||||
sub.unsubscribe()
|
||||
primeSub.unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
let menuContainerRef: HTMLDivElement | undefined
|
||||
|
||||
@@ -184,6 +212,21 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||
>
|
||||
wss://{r().subdomain}.spaces.coracle.social
|
||||
</a>
|
||||
<Show when={props.showTenant}>
|
||||
<A href={`/admin/tenants/${r().tenant_pubkey}`} class="group mt-1.5 flex w-fit items-center gap-2 min-w-0">
|
||||
<Show
|
||||
when={tenantProfile().picture}
|
||||
fallback={
|
||||
<div class="h-6 w-6 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
|
||||
{(tenantProfile().name || r().tenant_pubkey).slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img src={tenantProfile().picture} alt="" class="h-6 w-6 flex-shrink-0 rounded-full object-cover" />
|
||||
</Show>
|
||||
<span class="text-sm text-blue-600 group-hover:underline truncate">{tenantProfile().name || shortenPubkey(r().tenant_pubkey)}</span>
|
||||
</A>
|
||||
</Show>
|
||||
<Show when={r().info_description.trim()}>
|
||||
<p class="mt-2 text-sm text-gray-600">{r().info_description}</p>
|
||||
</Show>
|
||||
@@ -358,11 +401,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||
<Field label="Member limit">
|
||||
<span class="text-gray-900">{memberLimitLabel()}</span>
|
||||
</Field>
|
||||
<Show when={props.showTenant}>
|
||||
<Field label="Tenant">
|
||||
<span class="font-mono text-xs break-all">{r().tenant_pubkey}</span>
|
||||
</Field>
|
||||
</Show>
|
||||
</MembershipSection>
|
||||
|
||||
<Show when={showPlanActions()}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createSignal, For } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import type { Relay } from "@/lib/hooks"
|
||||
import { slugify } from "@/lib/slugify"
|
||||
import { validateSubdomainLabel } from "@/lib/subdomain"
|
||||
@@ -10,12 +10,16 @@ export type RelayFormValues = Pick<Relay, "info_name" | "subdomain" | "info_icon
|
||||
type RelayFormProps = {
|
||||
initialValues?: Partial<RelayFormValues>
|
||||
syncSubdomainWithName?: boolean
|
||||
// The plan can't be changed by editing a relay (only at creation or via the
|
||||
// detail card), so the edit form hides the selector by passing false here.
|
||||
showPlanSelector?: boolean
|
||||
onSubmit: (values: RelayFormValues) => Promise<void> | void
|
||||
submitLabel: string
|
||||
submittingLabel: string
|
||||
}
|
||||
|
||||
export default function RelayForm(props: RelayFormProps) {
|
||||
const showPlanSelector = () => props.showPlanSelector ?? true
|
||||
const defaultPlanId = createMemo(() => props.initialValues?.plan_id ?? plans()[0]?.id ?? "free")
|
||||
const [planId, setPlanId] = createSignal(defaultPlanId())
|
||||
const [name, setName] = createSignal(props.initialValues?.info_name ?? "")
|
||||
@@ -27,7 +31,7 @@ export default function RelayForm(props: RelayFormProps) {
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!planId()) {
|
||||
if (showPlanSelector() && !planId()) {
|
||||
setToastMessage("Please select a plan")
|
||||
return
|
||||
}
|
||||
@@ -105,28 +109,30 @@ export default function RelayForm(props: RelayFormProps) {
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">Plan</label>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<For each={plans()}>
|
||||
{(p) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlanId(p.id)}
|
||||
class={`border-2 rounded-xl p-4 text-left transition-colors ${planId() === p.id ? "border-blue-600 bg-blue-50" : "border-gray-200 hover:border-gray-300"}`}
|
||||
>
|
||||
<div class="font-bold text-gray-900">{p.name}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
{p.amount === 0 ? "Free" : `$${p.amount / 100}/mo`}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
{p.members === null ? "Unlimited members" : `Up to ${p.members} members`}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<Show when={showPlanSelector()}>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">Plan</label>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<For each={plans()}>
|
||||
{(p) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlanId(p.id)}
|
||||
class={`border-2 rounded-xl p-4 text-left transition-colors ${planId() === p.id ? "border-blue-600 bg-blue-50" : "border-gray-200 hover:border-gray-300"}`}
|
||||
>
|
||||
<div class="font-bold text-gray-900">{p.name}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
{p.amount === 0 ? "Free" : `$${p.amount / 100}/mo`}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
{p.members === null ? "Unlimited members" : `Up to ${p.members} members`}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting()}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { A } from "@solidjs/router"
|
||||
import { Show } from "solid-js"
|
||||
import { createEffect, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { getProfilePicture } from "applesauce-core/helpers/profile"
|
||||
import type { Relay } from "@/lib/api"
|
||||
import { eventStore } from "@/lib/state"
|
||||
|
||||
type RelayListItemProps = {
|
||||
relay: Relay
|
||||
@@ -8,17 +10,51 @@ type RelayListItemProps = {
|
||||
showTenant?: boolean
|
||||
}
|
||||
|
||||
function shortenPubkey(pubkey: string) {
|
||||
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
|
||||
}
|
||||
|
||||
export default function RelayListItem(props: RelayListItemProps) {
|
||||
const [tenantProfile, setTenantProfile] = createSignal<{ name?: string; picture?: string }>({})
|
||||
|
||||
// Resolve the owning tenant's profile from the event store. The list that
|
||||
// passes `showTenant` is responsible for priming these profiles in one batch.
|
||||
createEffect(() => {
|
||||
if (!props.showTenant) return
|
||||
const pubkey = props.relay.tenant_pubkey
|
||||
if (!pubkey) return
|
||||
|
||||
const sub = eventStore.profile(pubkey).subscribe((metadata) => {
|
||||
setTenantProfile({
|
||||
name: metadata?.name || metadata?.display_name,
|
||||
picture: getProfilePicture(metadata),
|
||||
})
|
||||
})
|
||||
onCleanup(() => sub.unsubscribe())
|
||||
})
|
||||
|
||||
return (
|
||||
<li>
|
||||
<A href={props.href} class="block rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-gray-900">{props.relay.info_name || props.relay.subdomain}</p>
|
||||
<p class="text-xs text-gray-500">{props.relay.subdomain}.spaces.coracle.social</p>
|
||||
{props.showTenant && (
|
||||
<p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant_pubkey}</p>
|
||||
)}
|
||||
<Show when={props.showTenant}>
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<Show
|
||||
when={tenantProfile().picture}
|
||||
fallback={
|
||||
<div class="h-5 w-5 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
|
||||
{(tenantProfile().name || props.relay.tenant_pubkey).slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img src={tenantProfile().picture} alt="" class="h-5 w-5 flex-shrink-0 rounded-full object-cover" />
|
||||
</Show>
|
||||
<span class="text-xs text-gray-500 truncate">{tenantProfile().name || shortenPubkey(props.relay.tenant_pubkey)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={props.relay.sync_error}
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import Fuse from "fuse.js"
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
||||
import PageContainer from "@/components/PageContainer"
|
||||
import RelayListItem from "@/components/RelayListItem"
|
||||
import ResourceState from "@/components/ResourceState"
|
||||
import SearchInput from "@/components/SearchInput"
|
||||
import useMinLoading from "@/components/useMinLoading"
|
||||
import { useAdminRelays } from "@/lib/hooks"
|
||||
import { primeProfiles, useAdminRelays } from "@/lib/hooks"
|
||||
|
||||
export default function AdminRelayList() {
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [relays] = useAdminRelays()
|
||||
const loading = useMinLoading(() => relays.loading)
|
||||
|
||||
// Each list item shows its tenant's profile; prime them all in one batch so
|
||||
// we don't open a separate outbox subscription per relay.
|
||||
createEffect(() => {
|
||||
const list = relays() ?? []
|
||||
if (!list.length) return
|
||||
const sub = primeProfiles(list.map(r => r.tenant_pubkey))
|
||||
onCleanup(() => sub.unsubscribe())
|
||||
})
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const list = relays() ?? []
|
||||
const q = query().trim()
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { For, Show } from "solid-js"
|
||||
import { createEffect, createSignal, For, onCleanup, Show } from "solid-js"
|
||||
import { getProfilePicture } from "applesauce-core/helpers/profile"
|
||||
import BackLink from "@/components/BackLink"
|
||||
import PageContainer from "@/components/PageContainer"
|
||||
import RelayListItem from "@/components/RelayListItem"
|
||||
import ResourceState from "@/components/ResourceState"
|
||||
import useMinLoading from "@/components/useMinLoading"
|
||||
import { useAdminTenant, useAdminTenantRelays } from "@/lib/hooks"
|
||||
import { primeProfiles, useAdminTenant, useAdminTenantRelays } from "@/lib/hooks"
|
||||
import { eventStore } from "@/lib/state"
|
||||
|
||||
function shortenPubkey(pubkey: string) {
|
||||
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
|
||||
}
|
||||
|
||||
export default function AdminTenantDetail() {
|
||||
const params = useParams()
|
||||
@@ -13,6 +19,28 @@ export default function AdminTenantDetail() {
|
||||
const [tenant] = useAdminTenant(tenantId)
|
||||
const [relays] = useAdminTenantRelays(tenantId)
|
||||
const loading = useMinLoading(() => tenant.loading || relays.loading)
|
||||
const [profile, setProfile] = createSignal<{ name?: string; picture?: string }>({})
|
||||
|
||||
createEffect(() => {
|
||||
const pubkey = tenantId()
|
||||
if (!pubkey) {
|
||||
setProfile({})
|
||||
return
|
||||
}
|
||||
|
||||
const profileSub = eventStore.profile(pubkey).subscribe((metadata) => {
|
||||
setProfile({
|
||||
name: metadata?.name || metadata?.display_name,
|
||||
picture: getProfilePicture(metadata),
|
||||
})
|
||||
})
|
||||
const primeSub = primeProfiles([pubkey])
|
||||
|
||||
onCleanup(() => {
|
||||
profileSub.unsubscribe()
|
||||
primeSub.unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
const churnedLabel = () => {
|
||||
const ts = tenant()?.churned_at
|
||||
@@ -23,7 +51,22 @@ export default function AdminTenantDetail() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<BackLink href="/admin/tenants" label="Tenants" />
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6 py-2">Tenant {params.id}</h1>
|
||||
<div class="flex items-center gap-4 mb-6 py-2">
|
||||
<Show
|
||||
when={profile().picture}
|
||||
fallback={
|
||||
<div class="h-14 w-14 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-lg">
|
||||
{(profile().name || tenantId()).slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img src={profile().picture} alt="Profile" class="h-14 w-14 flex-shrink-0 rounded-full object-cover" />
|
||||
</Show>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-bold text-gray-900 truncate">{profile().name || shortenPubkey(tenantId())}</h1>
|
||||
<p class="text-xs text-gray-500 break-all">{tenantId()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ResourceState loading={loading()} error={tenant.error || relays.error} loadingText="Loading tenant..." errorText="Failed to load tenant." class="mb-4" />
|
||||
<Show when={!loading()}>
|
||||
<div class="space-y-6">
|
||||
@@ -32,14 +75,18 @@ export default function AdminTenantDetail() {
|
||||
<Show when={tenant()}>
|
||||
{(t) => (
|
||||
<dl class="grid gap-y-3 text-sm">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 items-center">
|
||||
<dt class="text-gray-500">Status:</dt>
|
||||
<dd class="font-medium uppercase tracking-wide">{t().churned_at ? "delinquent" : "active"}</dd>
|
||||
<dd>
|
||||
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${t().churned_at ? "bg-red-50 text-red-700 border-red-200" : "bg-green-50 text-green-700 border-green-200"}`}>
|
||||
{t().churned_at ? "delinquent" : "active"}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<Show when={t().stripe_customer_id}>
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-gray-500">Stripe Customer:</dt>
|
||||
<dd class="font-mono text-xs">{t().stripe_customer_id}</dd>
|
||||
<dd class="font-mono">{t().stripe_customer_id}</dd>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={churnedLabel()}>
|
||||
|
||||
@@ -85,7 +85,9 @@ export default function AdminTenantList() {
|
||||
<p class="mt-1 text-xs text-gray-500 break-all">{tenant.pubkey}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs uppercase tracking-wide text-gray-500">tenant</p>
|
||||
<span class={`inline-flex flex-shrink-0 items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${tenant.churned_at ? "bg-red-50 text-red-700 border-red-200" : "bg-green-50 text-green-700 border-green-200"}`}>
|
||||
{tenant.churned_at ? "delinquent" : "active"}
|
||||
</span>
|
||||
</div>
|
||||
</A>
|
||||
</li>
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function RelayEdit(props: { basePath?: string; title?: string })
|
||||
<Show when={relay() && !loading()}>
|
||||
<RelayForm
|
||||
initialValues={relay()!}
|
||||
showPlanSelector={false}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Save Changes"
|
||||
submittingLabel="Saving..."
|
||||
|
||||
Reference in New Issue
Block a user