Opus refactor

This commit is contained in:
Jon Staab
2026-03-27 14:39:37 -07:00
parent 77ea366c69
commit 8986e5481d
16 changed files with 265 additions and 745 deletions
+10 -48
View File
@@ -2,54 +2,11 @@ import { A } from "@solidjs/router"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import type { Relay, PlanId } from "@/lib/api"
import menuDotsIcon from "@/assets/menu-dots-2.svg"
import Field from "@/components/Field"
import PricingTable from "@/components/PricingTable"
function Field(props: { label: string; children: any }) {
return (
<div>
<dt class="text-xs font-medium text-gray-500 uppercase tracking-wide">{props.label}</dt>
<dd class="mt-0.5 text-sm text-gray-900">{props.children}</dd>
</div>
)
}
function ToggleField(props: { label: string; children: any }) {
return (
<div class="flex items-center justify-between gap-3">
<dt class="text-xs font-medium text-gray-500 uppercase tracking-wide">{props.label}</dt>
<dd class="text-sm text-gray-900">{props.children}</dd>
</div>
)
}
function ToggleButton(props: {
enabled: boolean
disabled?: boolean
onToggle?: () => void
onLabel?: string
offLabel?: string
}) {
const label = () => (props.enabled ? (props.onLabel ?? "Enabled") : (props.offLabel ?? "Disabled"))
return (
<div class="inline-flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={props.enabled}
aria-label={label()}
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${props.enabled ? "bg-blue-600" : "bg-gray-300"} disabled:opacity-50`}
onClick={props.onToggle}
disabled={props.disabled}
>
<span
class={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${props.enabled ? "translate-x-5" : "translate-x-0.5"}`}
/>
</button>
<span class={`text-xs font-medium ${props.enabled ? "text-blue-700" : "text-gray-500"}`}>{label()}</span>
</div>
)
}
import ToggleButton from "@/components/ToggleButton"
import ToggleField from "@/components/ToggleField"
import { setToastMessage } from "@/components/Toast"
function DetailSection(props: { title: string; children: any }) {
return (
@@ -116,7 +73,12 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
async function changePlan(plan: PlanId) {
setPlan(plan)
props.onUpdatePlan?.(plan)
try {
await props.onUpdatePlan?.(plan)
setToastMessage(`Plan updated to ${plan}`, "success")
} catch {
// error is handled by the caller
}
}
createEffect(() => {
+27
View File
@@ -0,0 +1,27 @@
import { A } from "@solidjs/router"
import type { Relay } from "@/lib/api"
type RelayListItemProps = {
relay: Relay
href: string
showTenant?: boolean
}
export default function RelayListItem(props: RelayListItemProps) {
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>
<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}</p>
)}
</div>
<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p>
</div>
</A>
</li>
)
}
+25
View File
@@ -0,0 +1,25 @@
type SearchInputProps = {
value: string
onInput: (value: string) => void
placeholder?: string
}
export default function SearchInput(props: SearchInputProps) {
return (
<div class="relative">
<span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-gray-400">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.3-4.3" />
</svg>
</span>
<input
type="search"
value={props.value}
onInput={(e) => props.onInput(e.currentTarget.value)}
placeholder={props.placeholder ?? "Search..."}
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"
/>
</div>
)
}
+12 -2
View File
@@ -1,6 +1,14 @@
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
export const [toastMessage, setToastMessage] = createSignal("")
type ToastVariant = "error" | "success"
const [toastVariant, setToastVariant] = createSignal<ToastVariant>("error")
export const [toastMessage, setRawToastMessage] = createSignal("")
export function setToastMessage(message: string, variant: ToastVariant = "error") {
setToastVariant(variant)
setRawToastMessage(message)
}
export default function Toast() {
const [visible, setVisible] = createSignal(false)
@@ -57,10 +65,12 @@ export default function Toast() {
<Show when={toastMessage()}>
<div
role="alert"
class="fixed bottom-4 right-4 z-50 max-w-md rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-base text-red-700 shadow-lg transition-all duration-400 ease-out"
class="fixed bottom-4 right-4 z-50 max-w-md rounded-xl border px-4 py-3 text-base shadow-lg transition-all duration-400 ease-out"
classList={{
"translate-y-0 opacity-100 scale-100": visible(),
"translate-y-3 opacity-0 scale-95": !visible(),
"border-red-200 bg-red-50 text-red-700": toastVariant() === "error",
"border-green-200 bg-green-50 text-green-700": toastVariant() === "success",
}}
>
{toastMessage()}
+2 -2
View File
@@ -17,8 +17,6 @@ type AuthCache = {
expiresAt: number
}
let authCache: AuthCache | undefined
export class ApiError extends Error {
status: number
@@ -144,6 +142,8 @@ export type Identity = {
is_admin: boolean
}
let authCache: AuthCache | undefined
export async function makeAuth(): Promise<string | undefined> {
const current = account()
if (!current) return undefined
+3 -2
View File
@@ -51,7 +51,8 @@ export const [identity, { refetch: refetchIdentity, mutate: setIdentity }] = cre
}
)
;(() => {
// Deferred to avoid circular-import TDZ errors (api.ts <-> state.ts)
queueMicrotask(() => {
try {
accountManager.fromJSON(JSON.parse(localStorage.getItem("caravel.accounts")!))
} catch {
@@ -77,4 +78,4 @@ export const [identity, { refetch: refetchIdentity, mutate: setIdentity }] = cre
refetchIdentity()
})
})()
})
+101
View File
@@ -0,0 +1,101 @@
import { createSignal } from "solid-js"
import { updateRelayById, deactivateRelayById, type Relay } from "@/lib/hooks"
import { setToastMessage } from "@/components/Toast"
import type { PlanId } from "@/lib/api"
function toBool(value: number | undefined, fallback: boolean): boolean {
if (value === 0) return false
if (value === 1) return true
return fallback
}
function toInt(value: boolean): number {
return value ? 1 : 0
}
type RelayResource = {
(): Relay | undefined
loading: boolean
error: unknown
}
type RelayActions = {
refetch: (...args: unknown[]) => unknown
mutate: (v: Relay | undefined) => void
}
export default function useRelayToggles(
relayId: () => string,
relay: RelayResource,
{ refetch, mutate }: RelayActions,
) {
const [busy, setBusy] = createSignal(false)
async function updateRelay(next: Relay, previous: Relay) {
mutate(next)
try {
await updateRelayById(relayId(), next)
await refetch()
} catch (e) {
mutate(previous)
setToastMessage(e instanceof Error ? e.message : "Failed to update relay settings")
}
}
function toggle(field: keyof Relay, fallback: boolean) {
const current = relay()
if (!current) return
const next = { ...current, [field]: toInt(!toBool(current[field] as number, fallback)) }
void updateRelay(next, current)
}
async function handleDeactivate() {
if (busy()) return
setBusy(true)
try {
await deactivateRelayById(relayId())
await refetch()
} catch (e) {
setToastMessage(e instanceof Error ? e.message : "Failed to deactivate relay")
} finally {
setBusy(false)
}
}
async function handleUpdatePlan(plan: PlanId) {
const current = relay()
if (!current) return
const previous = current
const next = { ...current, plan }
const update: Record<string, unknown> = { plan }
if (plan === "free") {
next.blossom_enabled = 0
next.livekit_enabled = 0
update.blossom_enabled = 0
update.livekit_enabled = 0
}
mutate(next)
try {
await updateRelayById(relayId(), update)
await refetch()
} catch (e) {
mutate(previous)
setToastMessage(e instanceof Error ? e.message : "Failed to update relay plan")
throw e
}
}
const toggles = {
onTogglePublicJoin: () => toggle("policy_public_join", false),
onToggleStripSignatures: () => toggle("policy_strip_signatures", false),
onToggleGroups: () => toggle("groups_enabled", true),
onToggleManagement: () => toggle("management_enabled", true),
onToggleMediaStorage: () => toggle("blossom_enabled", relay()?.plan !== "free"),
onTogglePushNotifications: () => toggle("push_enabled", true),
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
}
return { busy, handleDeactivate, handleUpdatePlan, toggles }
}
+6 -133
View File
@@ -1,142 +1,24 @@
import { useParams } from "@solidjs/router"
import { createSignal, Show } from "solid-js"
import { 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"
import { useRelay } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles"
export default function AdminRelayDetail() {
const params = useParams()
const relayId = () => params.id ?? ""
const [relay, { refetch, mutate }] = useRelay(relayId)
const [busy, setBusy] = createSignal(false)
const [error, setError] = createSignal("")
const loading = useMinLoading(() => relay.loading && !relay())
async function handleDeactivate() {
if (busy()) return
setError("")
setBusy(true)
try {
await deactivateRelayById(relayId())
await refetch()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to deactivate relay")
} finally {
setBusy(false)
}
}
function toBool(value: number | undefined, fallback: boolean): boolean {
if (value === 0) return false
if (value === 1) return true
return fallback
}
function toInt(value: boolean): number {
return value ? 1 : 0
}
async function updateRelay(next: Relay, previous: Relay) {
const current = relay()
if (!current) return
setError("")
mutate(next)
try {
await updateRelayById(relayId(), next)
await refetch()
} catch (e) {
mutate(previous)
setError(e instanceof Error ? e.message : "Failed to update relay settings")
}
}
function togglePublicJoin() {
const current = relay()
if (!current) return
const next = {
...current,
policy_public_join: toInt(!toBool(current.policy_public_join, false)),
}
void updateRelay(next, current)
}
function toggleStripSignatures() {
const current = relay()
if (!current) return
const next = {
...current,
policy_strip_signatures: toInt(!toBool(current.policy_strip_signatures, false)),
}
void updateRelay(next, current)
}
function toggleGroups() {
const current = relay()
if (!current) return
const next = {
...current,
groups_enabled: toInt(!toBool(current.groups_enabled, true)),
}
void updateRelay(next, current)
}
function toggleManagement() {
const current = relay()
if (!current) return
const next = {
...current,
management_enabled: toInt(!toBool(current.management_enabled, true)),
}
void updateRelay(next, current)
}
function toggleMediaStorage() {
const current = relay()
if (!current) return
const next = {
...current,
blossom_enabled: toInt(!toBool(current.blossom_enabled, current.plan !== "free")),
}
void updateRelay(next, current)
}
function togglePushNotifications() {
const current = relay()
if (!current) return
const next = {
...current,
push_enabled: toInt(!toBool(current.push_enabled, true)),
}
void updateRelay(next, current)
}
function toggleLivekitSupport() {
const current = relay()
if (!current) return
const next = {
...current,
livekit_enabled: toInt(!toBool(current.livekit_enabled, current.plan !== "free")),
}
void updateRelay(next, current)
}
const { busy, handleDeactivate, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
return (
<PageContainer>
<BackLink href="/admin/relays" label="Relays" />
<ResourceState
loading={loading()}
error={relay.error}
loadingText="Loading relay..."
errorText="Failed to load relay."
class="mb-4"
/>
<ResourceState loading={loading()} error={relay.error} loadingText="Loading relay..." errorText="Failed to load relay." class="mb-4" />
<Show when={!loading() && relay()}>
{(r) => (
<div class="mb-6">
@@ -146,22 +28,13 @@ export default function AdminRelayDetail() {
editHref={`/admin/relays/${params.id}/edit`}
onDeactivate={handleDeactivate}
deactivating={busy()}
onTogglePublicJoin={togglePublicJoin}
onToggleStripSignatures={toggleStripSignatures}
onToggleGroups={toggleGroups}
onToggleManagement={toggleManagement}
onToggleMediaStorage={toggleMediaStorage}
onToggleLivekitSupport={toggleLivekitSupport}
onTogglePushNotifications={togglePushNotifications}
enforcePlanLimits={false}
showPlanActions={false}
{...toggles}
/>
</div>
)}
</Show>
<Show when={error()}>
<p class="mt-3 text-sm text-red-600">{error()}</p>
</Show>
</PageContainer>
)
}
+2 -47
View File
@@ -1,50 +1,5 @@
import { useNavigate, useParams } from "@solidjs/router"
import { Show } from "solid-js"
import RelayForm, { type RelayFormValues } 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"
import RelayEdit from "@/pages/relays/RelayEdit"
export default function AdminRelayEdit() {
const navigate = useNavigate()
const params = useParams()
const relayId = () => params.id ?? ""
const [relay] = useRelay(relayId)
const loading = useMinLoading(() => relay.loading)
async function handleSubmit(values: RelayFormValues) {
await updateRelayById(relayId(), {
subdomain: slugify(values.subdomain),
info_name: values.info_name.trim(),
info_icon: values.info_icon.trim(),
info_description: values.info_description.trim(),
})
navigate(`/admin/relays/${relayId()}`)
}
return (
<PageContainer size="narrow">
<BackLink href={`/admin/relays/${params.id}`} label="Back" />
<h1 class="text-2xl font-bold text-gray-900 mb-6 py-2">Edit Relay (Admin)</h1>
<ResourceState
loading={loading()}
error={relay.error}
loadingText="Loading relay..."
errorText="Failed to load relay."
/>
<Show when={relay() && !loading()}>
<RelayForm
initialValues={relay()!}
onSubmit={handleSubmit}
submitLabel="Save Changes"
submittingLabel="Saving..."
/>
</Show>
</PageContainer>
)
return <RelayEdit basePath="/admin/relays" title="Edit Relay (Admin)" />
}
+8 -46
View File
@@ -1,8 +1,9 @@
import { A } from "@solidjs/router"
import Fuse from "fuse.js"
import { createMemo, createSignal, For, 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"
@@ -14,61 +15,22 @@ export default function AdminRelayList() {
const filtered = createMemo(() => {
const list = relays() ?? []
const q = query().trim()
if (!q) return list
return new Fuse(list, {
keys: ["info_name", "subdomain", "tenant"],
threshold: 0.35,
ignoreLocation: true,
}).search(q).map((result) => result.item)
return new Fuse(list, { keys: ["info_name", "subdomain", "tenant"], threshold: 0.35, ignoreLocation: true }).search(q).map(r => r.item)
})
return (
<PageContainer>
<h1 class="text-2xl font-bold text-gray-900 mb-6 py-2">Relays</h1>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 py-2">Relays</h1>
<SearchInput value={query()} onInput={setQuery} placeholder="Search relays..." />
</div>
<div class="relative mb-6">
<span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-gray-400">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.3-4.3" />
</svg>
</span>
<input
type="search"
value={query()}
onInput={(e) => 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"
/>
</div>
<ResourceState
loading={loading()}
error={relays.error}
loadingText="Loading relays..."
errorText="Failed to load relays."
/>
<ResourceState loading={loading()} error={relays.error} loadingText="Loading relays..." errorText="Failed to load relays." />
<Show when={!loading()}>
<Show when={(filtered().length ?? 0) > 0} fallback={<p class="py-20 text-center text-gray-500">No relays found.</p>}>
<Show when={filtered().length > 0} fallback={<p class="py-20 text-center text-gray-500">No relays found.</p>}>
<ul class="space-y-3">
<For each={filtered()}>
{(relay) => (
<li>
<A href={`/admin/relays/${relay.id}`} class="block border border-gray-200 rounded-lg p-4 bg-white hover:border-gray-300">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-gray-900">{relay.info_name || relay.subdomain}</p>
<p class="text-xs text-gray-500">{relay.subdomain}.spaces.coracle.social</p>
<p class="text-xs text-gray-500 break-all mt-1">Tenant: {relay.tenant}</p>
</div>
<p class="text-xs uppercase tracking-wide text-gray-500">{relay.status}</p>
</div>
</A>
</li>
)}
{(relay) => <RelayListItem relay={relay} href={`/admin/relays/${relay.id}`} showTenant />}
</For>
</ul>
</Show>
+5 -29
View File
@@ -1,7 +1,8 @@
import { useParams, A } from "@solidjs/router"
import { useParams } from "@solidjs/router"
import { For, Show } from "solid-js"
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"
@@ -17,46 +18,21 @@ export default function AdminTenantDetail() {
<PageContainer>
<BackLink href="/admin/tenants" label="Tenants" />
<h1 class="text-2xl font-bold text-gray-900 mb-6 py-2">Tenant {params.id}</h1>
<ResourceState
loading={loading()}
error={tenant.error || relays.error}
loadingText="Loading tenant..."
errorText="Failed to load tenant."
class="mb-4"
/>
<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">
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h2 class="text-lg font-semibold mb-4">Status</h2>
<Show when={tenant()}>
<div class="space-y-3">
<p class="text-sm text-gray-700">
Current: <span class="font-medium uppercase tracking-wide">tenant</span>
</p>
</div>
<p class="text-sm text-gray-700">Current: <span class="font-medium uppercase tracking-wide">tenant</span></p>
</Show>
</section>
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h2 class="text-lg font-semibold mb-4">Relays</h2>
<Show when={(relays()?.length ?? 0) > 0} fallback={<p class="text-gray-500">No relays.</p>}>
<ul class="space-y-3">
<For each={relays() ?? []}>
{(relay) => (
<li>
<A href={`/admin/relays/${relay.id}`} class="block rounded-lg border border-gray-200 p-3 hover:border-gray-300">
<div class="flex items-center justify-between gap-2">
<div>
<p class="font-medium text-gray-900">{relay.info_name || relay.subdomain}</p>
<p class="text-xs text-gray-500">{relay.subdomain}.spaces.coracle.social</p>
</div>
<span class="text-xs uppercase tracking-wide text-gray-500">{relay.status}</span>
</div>
</A>
</li>
)}
{(relay) => <RelayListItem relay={relay} href={`/admin/relays/${relay.id}`} />}
</For>
</ul>
</Show>
+27 -57
View File
@@ -4,40 +4,29 @@ import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "so
import { getProfilePicture } from "applesauce-core/helpers/profile"
import PageContainer from "@/components/PageContainer"
import ResourceState from "@/components/ResourceState"
import SearchInput from "@/components/SearchInput"
import useMinLoading from "@/components/useMinLoading"
import { primeProfiles, useAdminTenants } from "@/lib/hooks"
import { eventStore } from "@/lib/state"
function shortenPubkey(pubkey: string) {
if (pubkey.length <= 16) return pubkey
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
export default function AdminTenantList() {
const [query, setQuery] = createSignal("")
const [tenants] = useAdminTenants()
const [profiles, setProfiles] = createSignal<Record<string, { name?: string, about?: string, nip05?: string, picture?: string }>>({})
const [profiles, setProfiles] = createSignal<Record<string, { name?: string; about?: string; nip05?: string; picture?: string }>>({})
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,
}
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)
return new Fuse(list, { keys: ["pubkey", "status", "profileName", "profileAbout", "profileNip05"], threshold: 0.35, ignoreLocation: true }).search(q).map(r => r.item)
})
createEffect(() => {
@@ -69,56 +58,37 @@ export default function AdminTenantList() {
return (
<PageContainer>
<h1 class="text-2xl font-bold text-gray-900 mb-6 py-2">Tenants</h1>
<div class="relative mb-6">
<span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-gray-400">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.3-4.3" />
</svg>
</span>
<input
type="search"
value={query()}
onInput={(e) => 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"
/>
<div class="mb-6">
<SearchInput value={query()} onInput={setQuery} placeholder="Search tenants..." />
</div>
<ResourceState
loading={loading()}
error={tenants.error}
loadingText="Loading tenants..."
errorText="Failed to load tenants."
/>
<ResourceState loading={loading()} error={tenants.error} loadingText="Loading tenants..." errorText="Failed to load tenants." />
<Show when={!loading()}>
<Show when={(filtered().length ?? 0) > 0} fallback={<p class="py-20 text-center text-gray-500">No tenants found.</p>}>
<Show when={filtered().length > 0} fallback={<p class="py-20 text-center text-gray-500">No tenants found.</p>}>
<ul class="space-y-3">
<For each={filtered()}>
{(tenant) => {
const profile = () => profiles()[tenant.pubkey]
return (
<li>
<A href={`/admin/tenants/${tenant.pubkey}`} class="block border border-gray-200 rounded-lg p-4 bg-white hover:border-gray-300">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex items-start gap-3">
<Show
when={profile()?.picture}
fallback={<div class="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-xs">{(profile()?.name || tenant.pubkey).slice(0, 1).toUpperCase()}</div>}
>
<img src={profile()?.picture} alt="Profile" class="h-10 w-10 rounded-full object-cover" />
</Show>
<div class="min-w-0">
<p class="font-medium text-gray-900 truncate">{profile()?.name || shortenPubkey(tenant.pubkey)}</p>
<p class="mt-1 text-sm text-gray-600 line-clamp-2">{profile()?.about || "No profile bio"}</p>
<p class="mt-1 text-xs text-gray-500 break-all">{tenant.pubkey}</p>
<li>
<A href={`/admin/tenants/${tenant.pubkey}`} class="block border border-gray-200 rounded-lg p-4 bg-white hover:border-gray-300">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex items-start gap-3">
<Show
when={profile()?.picture}
fallback={<div class="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-xs">{(profile()?.name || tenant.pubkey).slice(0, 1).toUpperCase()}</div>}
>
<img src={profile()?.picture} alt="Profile" class="h-10 w-10 rounded-full object-cover" />
</Show>
<div class="min-w-0">
<p class="font-medium text-gray-900 truncate">{profile()?.name || shortenPubkey(tenant.pubkey)}</p>
<p class="mt-1 text-sm text-gray-600 line-clamp-2">{profile()?.about || "No profile bio"}</p>
<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>
</div>
<p class="text-xs uppercase tracking-wide text-gray-500">tenant</p>
</div>
</A>
</li>
</A>
</li>
)
}}
</For>
+6 -160
View File
@@ -1,12 +1,12 @@
import { useParams } from "@solidjs/router"
import { createMemo, createResource, createSignal, Show } from "solid-js"
import type { PlanId } from "@/lib/api"
import { createMemo, createResource, 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, getRelayMembers, updateRelayById, updateRelayPlanById, useRelay, type Relay } from "@/lib/hooks"
import { getRelayMembers, useRelay } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles"
export default function RelayDetail() {
const params = useParams()
@@ -17,158 +17,13 @@ export default function RelayDetail() {
return subdomain ? `wss://${subdomain}.spaces.coracle.social` : undefined
})
const [members] = createResource(relayUrl, getRelayMembers)
const [busy, setBusy] = createSignal(false)
const [error, setError] = createSignal("")
const loading = useMinLoading(() => relay.loading && !relay())
async function handleDeactivate() {
if (busy()) return
setError("")
setBusy(true)
try {
await deactivateRelayById(relayId())
await refetch()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to deactivate relay")
} finally {
setBusy(false)
}
}
function toBool(value: number | undefined, fallback: boolean): boolean {
if (value === 0) return false
if (value === 1) return true
return fallback
}
function toInt(value: boolean): number {
return value ? 1 : 0
}
async function updateRelay(next: Relay, previous: Relay) {
const current = relay()
if (!current) return
setError("")
mutate(next)
try {
await updateRelayById(relayId(), next)
await refetch()
} catch (e) {
mutate(previous)
setError(e instanceof Error ? e.message : "Failed to update relay settings")
}
}
function togglePublicJoin() {
const current = relay()
if (!current) return
const next = {
...current,
policy_public_join: toInt(!toBool(current.policy_public_join, false)),
}
void updateRelay(next, current)
}
function toggleStripSignatures() {
const current = relay()
if (!current) return
const next = {
...current,
policy_strip_signatures: toInt(!toBool(current.policy_strip_signatures, false)),
}
void updateRelay(next, current)
}
function toggleGroups() {
const current = relay()
if (!current) return
const next = {
...current,
groups_enabled: toInt(!toBool(current.groups_enabled, true)),
}
void updateRelay(next, current)
}
function toggleManagement() {
const current = relay()
if (!current) return
const next = {
...current,
management_enabled: toInt(!toBool(current.management_enabled, true)),
}
void updateRelay(next, current)
}
function toggleMediaStorage() {
const current = relay()
if (!current) return
const next = {
...current,
blossom_enabled: toInt(!toBool(current.blossom_enabled, current.plan !== "free")),
}
void updateRelay(next, current)
}
function togglePushNotifications() {
const current = relay()
if (!current) return
const next = {
...current,
push_enabled: toInt(!toBool(current.push_enabled, true)),
}
void updateRelay(next, current)
}
function toggleLivekitSupport() {
const current = relay()
if (!current) return
const next = {
...current,
livekit_enabled: toInt(!toBool(current.livekit_enabled, current.plan !== "free")),
}
void updateRelay(next, current)
}
async function handleUpdatePlan(plan: PlanId) {
const current = relay()
if (!current) return
const previous = current
setError("")
const next = { ...current }
if (plan === "free") {
next.blossom_enabled = 0
next.livekit_enabled = 0
}
next.plan = plan
mutate(next)
try {
await updateRelayPlanById(relayId(), plan)
await refetch()
} catch (e) {
mutate(previous)
setError(e instanceof Error ? e.message : "Failed to update relay plan")
throw e
}
}
const { busy, handleDeactivate, handleUpdatePlan, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
return (
<PageContainer>
<BackLink href="/relays" label="Relays" />
<ResourceState
loading={loading()}
error={relay.error}
loadingText="Loading relay..."
errorText="Failed to load relay."
class="mb-4"
/>
<ResourceState loading={loading()} error={relay.error} loadingText="Loading relay..." errorText="Failed to load relay." class="mb-4" />
<Show when={!loading() && relay()}>
{(r) => (
<div class="mb-6">
@@ -178,21 +33,12 @@ export default function RelayDetail() {
editHref={`/relays/${params.id}/edit`}
onDeactivate={handleDeactivate}
deactivating={busy()}
onTogglePublicJoin={togglePublicJoin}
onToggleStripSignatures={toggleStripSignatures}
onToggleGroups={toggleGroups}
onToggleManagement={toggleManagement}
onToggleMediaStorage={toggleMediaStorage}
onToggleLivekitSupport={toggleLivekitSupport}
onTogglePushNotifications={togglePushNotifications}
onUpdatePlan={handleUpdatePlan}
{...toggles}
/>
</div>
)}
</Show>
<Show when={error()}>
<p class="mt-3 text-sm text-red-600">{error()}</p>
</Show>
</PageContainer>
)
}
+6 -12
View File
@@ -8,7 +8,8 @@ import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import { updateRelayById, useRelay } from "@/lib/hooks"
export default function RelayEdit() {
export default function RelayEdit(props: { basePath?: string; title?: string }) {
const basePath = () => props.basePath ?? "/relays"
const navigate = useNavigate()
const params = useParams()
const relayId = () => params.id ?? ""
@@ -22,21 +23,14 @@ export default function RelayEdit() {
info_icon: values.info_icon.trim(),
info_description: values.info_description.trim(),
})
navigate(`/relays/${relayId()}`)
navigate(`${basePath()}/${relayId()}`)
}
return (
<PageContainer size="narrow">
<BackLink href={`/relays/${params.id}`} label="Back" />
<h1 class="text-2xl font-bold text-gray-900 mb-6 py-2">Edit Relay</h1>
<ResourceState
loading={loading()}
error={relay.error}
loadingText="Loading relay..."
errorText="Failed to load relay."
/>
<BackLink href={`${basePath()}/${params.id}`} label="Back" />
<h1 class="text-2xl font-bold text-gray-900 mb-6 py-2">{props.title ?? "Edit Relay"}</h1>
<ResourceState loading={loading()} error={relay.error} loadingText="Loading relay..." errorText="Failed to load relay." />
<Show when={relay() && !loading()}>
<RelayForm
initialValues={relay()!}
+10 -62
View File
@@ -2,7 +2,9 @@ import { A } from "@solidjs/router"
import Fuse from "fuse.js"
import { createMemo, createSignal, For, 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 { useTenantRelays } from "@/lib/hooks"
@@ -16,52 +18,20 @@ export default function RelayList() {
const list = relays() ?? []
const q = query().trim()
const searched = q
? new Fuse(list, {
keys: ["info_name", "subdomain"],
threshold: 0.35,
ignoreLocation: true,
}).search(q).map((result) => result.item)
? new Fuse(list, { keys: ["info_name", "subdomain"], threshold: 0.35, ignoreLocation: true }).search(q).map(r => r.item)
: list
return searched.filter((relay) => {
const matchesStatus = status() === "all" || relay.status === status()
return matchesStatus
})
return status() === "all" ? searched : searched.filter(r => r.status === status())
})
return (
<PageContainer>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900 py-2">My Relays</h1>
<A
href="/relays/new"
class="py-2 px-4 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Add Relay
</A>
<A href="/relays/new" class="py-2 px-4 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors">Add Relay</A>
</div>
<div class="mb-6 grid gap-3 sm:grid-cols-[1fr_auto]">
<div class="relative">
<span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-gray-400">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.3-4.3" />
</svg>
</span>
<input
type="search"
value={query()}
onInput={(e) => setQuery(e.currentTarget.value)}
placeholder="Search by name or subdomain"
class="w-full border border-gray-300 rounded-lg py-2 pl-10 pr-3"
/>
</div>
<select
value={status()}
onChange={(e) => setStatus(e.currentTarget.value)}
class="border border-gray-300 rounded-lg px-3 py-2 bg-white"
>
<SearchInput value={query()} onInput={setQuery} placeholder="Search by name or subdomain" />
<select value={status()} onChange={e => setStatus(e.currentTarget.value)} class="border border-gray-300 rounded-lg px-3 py-2 bg-white">
<option value="all">All statuses</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
@@ -70,34 +40,12 @@ export default function RelayList() {
<option value="suspended">Suspended</option>
</select>
</div>
<ResourceState
loading={loading()}
error={relays.error}
loadingText="Loading relays..."
errorText="Failed to load relays."
/>
<ResourceState loading={loading()} error={relays.error} loadingText="Loading relays..." errorText="Failed to load relays." />
<Show when={!loading()}>
<Show when={(filtered().length ?? 0) > 0} fallback={<p class="py-20 text-center text-gray-500">No relays found.</p>}>
<Show when={filtered().length > 0} fallback={<p class="py-20 text-center text-gray-500">No relays found.</p>}>
<ul class="space-y-3">
<For each={filtered()}>
{(relay) => (
<li>
<A
href={`/relays/${relay.id}`}
class="block rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300"
>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900">{relay.info_name || relay.subdomain}</p>
<p class="text-sm text-gray-500">https://{relay.subdomain}.spaces.coracle.social</p>
</div>
<p class="text-xs uppercase tracking-wide text-gray-500">{relay.status}</p>
</div>
</A>
</li>
)}
{(relay) => <RelayListItem relay={relay} href={`/relays/${relay.id}`} />}
</For>
</ul>
</Show>
+15 -145
View File
@@ -1,157 +1,27 @@
import { Show, createSignal } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { slugify } from "@/lib/slugify"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { createRelayForActiveTenant } from "@/lib/hooks"
import { PLANS, type PlanId } from "@/lib/api"
export default function RelayNew() {
const navigate = useNavigate()
const [name, setName] = createSignal("")
const [subdomain, setSubdomain] = createSignal("")
const [icon, setIcon] = createSignal("")
const [description, setDescription] = createSignal("")
const [plan, setPlan] = createSignal<PlanId>("free")
const [submitting, setSubmitting] = createSignal(false)
const [subdomainError, setSubdomainError] = createSignal("")
function handleNameInput(value: string) {
setName(value)
setSubdomain(slugify(value))
setSubdomainError("")
}
async function handleSubmit(e: Event) {
e.preventDefault()
setSubdomainError("")
setSubmitting(true)
try {
const relay = await createRelayForActiveTenant({
subdomain: slugify(subdomain()),
plan: plan(),
info_name: name().trim(),
info_icon: icon().trim(),
info_description: description().trim(),
policy_public_join: 0,
policy_strip_signatures: 0,
groups_enabled: 1,
management_enabled: 1,
blossom_enabled: plan() === "free" ? 0 : 1,
livekit_enabled: plan() === "free" ? 0 : 1,
push_enabled: 1,
})
navigate(`/relays/${relay.id}`)
} catch (e) {
setSubdomainError(e instanceof Error ? e.message : "Failed to create relay")
} finally {
setSubmitting(false)
}
async function handleSubmit(values: RelayFormValues) {
const relay = await createRelayForActiveTenant(values)
navigate(`/relays/${relay.id}`)
}
return (
<div class="max-w-2xl mx-auto px-4 py-8">
<PageContainer size="narrow">
<BackLink href="/relays" label="Relays" />
<h1 class="text-2xl font-bold text-gray-900 mb-6 py-2">New Relay</h1>
<form onSubmit={handleSubmit} class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Relay Name</label>
<input
type="text"
required
value={name()}
onInput={e => handleNameInput(e.currentTarget.value)}
placeholder="My Community"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Subdomain</label>
<div class="relative">
<div
class="flex items-center border rounded-lg overflow-hidden focus-within:ring-2"
classList={{
"border-gray-300 focus-within:ring-blue-500": !subdomainError(),
"border-red-400 focus-within:ring-red-400": !!subdomainError(),
}}
>
<input
type="text"
required
value={subdomain()}
onInput={e => {
setSubdomain(e.currentTarget.value)
setSubdomainError("")
}}
placeholder="my-community"
class="flex-1 px-3 py-2 focus:outline-none"
/>
<span class="px-3 py-2 bg-gray-50 text-gray-500 text-sm border-l border-gray-300">
.spaces.coracle.social
</span>
</div>
<Show when={subdomainError()}>
<div class="pointer-events-none absolute right-0 top-full z-10 mt-2 rounded-md bg-red-600 px-3 py-2 text-xs text-white shadow-lg">
{subdomainError()}
</div>
</Show>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Icon URL</label>
<input
type="url"
value={icon()}
onInput={e => setIcon(e.currentTarget.value)}
placeholder="https://example.com/icon.png"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
value={description()}
onInput={e => setDescription(e.currentTarget.value)}
placeholder="A community for..."
rows={3}
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Plan</label>
<div class="grid grid-cols-3 gap-3">
{PLANS.map(p => (
<button
type="button"
onClick={() => setPlan(p.id)}
class={`border-2 rounded-xl p-4 text-left transition-colors ${
plan() === p.id
? "border-blue-600 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
}`}
>
<div class="font-bold text-gray-900">{p.label}</div>
<div class="text-sm text-gray-500 mt-1">
{p.price === 0 ? "Free" : `${p.price.toLocaleString()} sats/mo`}
</div>
<div class="text-xs text-gray-500 mt-2">{p.memberLabel}</div>
</button>
))}
</div>
</div>
<button
type="submit"
disabled={submitting()}
class="w-full py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors"
>
{submitting() ? "Creating..." : "Create Relay"}
</button>
</form>
</div>
<RelayForm
syncSubdomainWithName
onSubmit={handleSubmit}
submitLabel="Create Relay"
submittingLabel="Creating..."
/>
</PageContainer>
)
}