Simplify relay upsert

This commit is contained in:
Jon Staab
2026-03-03 09:08:54 -08:00
parent 6618025b54
commit 46a270513e
13 changed files with 495 additions and 242 deletions
+13
View File
@@ -0,0 +1,13 @@
export default function Checkbox(props: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={props.checked}
onChange={e => props.onChange(e.currentTarget.checked)}
class="w-4 h-4 rounded border-gray-300 text-blue-600"
/>
<span class="text-sm text-gray-700">{props.label}</span>
</label>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { For, Show } from "solid-js"
import type { Relay } from "../lib/api"
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 Badge(props: { on: boolean; onLabel?: string; offLabel?: string }) {
const label = () => (props.on ? (props.onLabel ?? "Yes") : (props.offLabel ?? "No"))
return (
<span class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${props.on ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"}`}>
{label()}
</span>
)
}
function Section(props: { title: string; children: any }) {
return (
<div>
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">{props.title}</h3>
<dl class="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">
{props.children}
</dl>
</div>
)
}
type RelayDetailCardProps = {
relay: Relay
showTenant?: boolean
}
export default function RelayDetailCard(props: RelayDetailCardProps) {
const r = () => props.relay
const cfg = () => r().config
return (
<div class="space-y-6">
{/* Header */}
<div class="flex items-start gap-4">
<Show when={r().icon}>
<img src={r().icon} alt="" class="w-14 h-14 rounded-xl object-cover flex-shrink-0 border border-gray-200" />
</Show>
<div class="min-w-0">
<h1 class="text-2xl font-bold text-gray-900">{r().name}</h1>
<a
href={`wss://${r().subdomain}.spaces.coracle.social`}
class="text-sm text-blue-600 hover:underline break-all"
>
wss://{r().subdomain}.spaces.coracle.social
</a>
<Show when={r().description.trim()}>
<p class="mt-2 text-sm text-gray-600">{r().description}</p>
</Show>
</div>
</div>
<hr class="border-gray-200" />
{/* Relay info */}
<Section title="Relay">
<Field label="Plan">
<span class="capitalize">{r().plan}</span>
</Field>
<Field label="Status">
<span class={`capitalize font-medium ${r().status === "active" ? "text-green-600" : r().status === "deactivated" ? "text-red-500" : "text-yellow-600"}`}>
{r().status}
</span>
</Field>
<Field label="Schema">{r().schema}</Field>
<Show when={props.showTenant}>
<Field label="Tenant">
<span class="font-mono text-xs break-all">{r().tenant}</span>
</Field>
</Show>
</Section>
<hr class="border-gray-200" />
{/* Config sections */}
<Show when={cfg()}>
<Section title="Policy">
<Field label="Public join">
<Badge on={cfg().policy.public_join} onLabel="Enabled" offLabel="Disabled" />
</Field>
<Field label="Strip signatures">
<Badge on={cfg().policy.strip_signatures} onLabel="Enabled" offLabel="Disabled" />
</Field>
</Section>
<hr class="border-gray-200" />
<Section title="Groups (NIP 29)">
<Field label="Enabled">
<Badge on={cfg().groups.enabled} />
</Field>
<Field label="Auto-join">
<Badge on={cfg().groups.auto_join} />
</Field>
</Section>
<hr class="border-gray-200" />
<Section title="Management (NIP 86)">
<Field label="Enabled">
<Badge on={cfg().management.enabled} />
</Field>
</Section>
<hr class="border-gray-200" />
<Section title="Features">
<Field label="Blossom">
<Badge on={cfg().blossom.enabled} onLabel="Enabled" offLabel="Disabled" />
</Field>
<Field label="Push (NIP 9a)">
<Badge on={cfg().push.enabled} onLabel="Enabled" offLabel="Disabled" />
</Field>
</Section>
</Show>
</div>
)
}
+72 -1
View File
@@ -1,3 +1,6 @@
import type { RelayConfig } from "../lib/api"
import Checkbox from "./Checkbox"
type RelayFormProps = {
name: string
setName: (value: string) => void
@@ -10,6 +13,8 @@ type RelayFormProps = {
plan: string
setPlan: (value: string) => void
plans: readonly string[]
config: RelayConfig
setConfig: (value: RelayConfig) => void
onSubmit: (e: Event) => void
submitting: boolean
error?: string
@@ -18,8 +23,13 @@ type RelayFormProps = {
}
export default function RelayForm(props: RelayFormProps) {
function patchConfig(patch: Partial<RelayConfig>) {
props.setConfig({ ...props.config, ...patch })
}
return (
<form onSubmit={props.onSubmit} class="space-y-4">
<form onSubmit={props.onSubmit} class="space-y-6">
{/* Basic info */}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Relay Name</label>
<input
@@ -73,6 +83,67 @@ export default function RelayForm(props: RelayFormProps) {
))}
</div>
</div>
{/* Policy */}
<fieldset class="border border-gray-200 rounded-lg p-4 space-y-3">
<legend class="text-sm font-semibold text-gray-700 px-1">Policy</legend>
<Checkbox
label="Allow public join (anyone can join without an invite)"
checked={props.config.policy.public_join}
onChange={v => patchConfig({ policy: { ...props.config.policy, public_join: v } })}
/>
<Checkbox
label="Strip signatures when serving events to non-admins"
checked={props.config.policy.strip_signatures}
onChange={v => patchConfig({ policy: { ...props.config.policy, strip_signatures: v } })}
/>
</fieldset>
{/* Groups (NIP 29) */}
<fieldset class="border border-gray-200 rounded-lg p-4 space-y-3">
<legend class="text-sm font-semibold text-gray-700 px-1">Groups (NIP 29)</legend>
<Checkbox
label="Enable NIP 29 groups"
checked={props.config.groups.enabled}
onChange={v => patchConfig({ groups: { ...props.config.groups, enabled: v } })}
/>
<Checkbox
label="Allow members to join groups without approval"
checked={props.config.groups.auto_join}
onChange={v => patchConfig({ groups: { ...props.config.groups, auto_join: v } })}
/>
</fieldset>
{/* Management (NIP 86) */}
<fieldset class="border border-gray-200 rounded-lg p-4 space-y-3">
<legend class="text-sm font-semibold text-gray-700 px-1">Management (NIP 86)</legend>
<Checkbox
label="Enable NIP 86 relay management"
checked={props.config.management.enabled}
onChange={v => patchConfig({ management: { enabled: v } })}
/>
</fieldset>
{/* Blossom */}
<fieldset class="border border-gray-200 rounded-lg p-4">
<legend class="text-sm font-semibold text-gray-700 px-1">Blossom</legend>
<Checkbox
label="Enable Blossom media storage"
checked={props.config.blossom.enabled}
onChange={v => patchConfig({ blossom: { enabled: v } })}
/>
</fieldset>
{/* Push (NIP 9a) */}
<fieldset class="border border-gray-200 rounded-lg p-4">
<legend class="text-sm font-semibold text-gray-700 px-1">Push (NIP 9a)</legend>
<Checkbox
label="Enable NIP 9a push notifications"
checked={props.config.push.enabled}
onChange={v => patchConfig({ push: { enabled: v } })}
/>
</fieldset>
{props.error && <p class="text-sm text-red-600">{props.error}</p>}
<button
type="submit"
+24
View File
@@ -87,6 +87,27 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return response.json() as Promise<T>
}
export type RelayConfig = {
policy: {
public_join: boolean
strip_signatures: boolean
}
groups: {
enabled: boolean
auto_join: boolean
}
management: {
enabled: boolean
}
blossom: {
enabled: boolean
}
push: {
enabled: boolean
}
}
export type Relay = {
id: string
tenant: string
@@ -97,6 +118,7 @@ export type Relay = {
description: string
plan: string
status: string
config: RelayConfig
}
export type Tenant = {
@@ -125,6 +147,7 @@ export type UpdateRelayInput = {
icon: string
description: string
plan: string
config: RelayConfig
}
export type AdminCheck = {
@@ -145,6 +168,7 @@ export type CreateRelayInput = {
icon: string
description: string
plan: string
config: RelayConfig
}
export function createTenantRelay(input: CreateRelayInput) {
@@ -3,6 +3,7 @@ import { createResource, createSignal, Show } from "solid-js"
import { adminDeactivateRelay, adminGetRelay } from "../../lib/api"
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"
@@ -43,14 +44,7 @@ export default function AdminRelayDetail() {
<Show when={!loading() && relay()}>
{(r) => (
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 mb-1 py-2">{r().name}</h1>
<p class="text-sm text-gray-500">{r().subdomain}.spaces.coracle.social</p>
<p class="text-sm text-gray-500 mt-2 break-all">Tenant: {r().tenant}</p>
<p class="text-sm text-gray-700 mt-2">Plan: <span class="uppercase">{r().plan}</span></p>
<p class="text-sm text-gray-700">Status: <span class="uppercase">{r().status}</span></p>
<Show when={r().description.trim()}>
<p class="mt-3 text-gray-700">{r().description}</p>
</Show>
<RelayDetailCard relay={r()} showTenant />
</div>
)}
</Show>
+14 -1
View File
@@ -1,6 +1,6 @@
import { useNavigate, useParams } from "@solidjs/router"
import { Show, createEffect, createResource, createSignal } from "solid-js"
import { adminGetRelay, adminUpdateRelay } from "../../lib/api"
import { adminGetRelay, adminUpdateRelay, type RelayConfig } from "../../lib/api"
import RelayForm from "../../components/RelayForm"
import { RELAY_PLAN_IDS, type RelayPlanId } from "../../lib/relayPlans"
import { slugify } from "../../lib/slugify"
@@ -9,6 +9,14 @@ import PageContainer from "../../components/PageContainer"
import ResourceState from "../../components/ResourceState"
import useMinLoading from "../../components/useMinLoading"
const DEFAULT_CONFIG: RelayConfig = {
policy: { public_join: false, strip_signatures: false },
groups: { enabled: false, auto_join: false },
management: { enabled: false },
blossom: { enabled: false },
push: { enabled: false },
}
export default function AdminRelayEdit() {
const navigate = useNavigate()
const params = useParams()
@@ -21,6 +29,7 @@ export default function AdminRelayEdit() {
const [icon, setIcon] = createSignal("")
const [description, setDescription] = createSignal("")
const [plan, setPlan] = createSignal<RelayPlanId>("free")
const [config, setConfig] = createSignal<RelayConfig>(DEFAULT_CONFIG)
const [error, setError] = createSignal("")
const [submitting, setSubmitting] = createSignal(false)
@@ -32,6 +41,7 @@ export default function AdminRelayEdit() {
setIcon(data.icon)
setDescription(data.description)
setPlan(RELAY_PLAN_IDS.includes(data.plan as RelayPlanId) ? (data.plan as RelayPlanId) : "free")
setConfig(data.config ?? DEFAULT_CONFIG)
})
async function handleSubmit(e: Event) {
@@ -45,6 +55,7 @@ export default function AdminRelayEdit() {
icon: icon().trim(),
description: description().trim(),
plan: plan(),
config: config(),
})
navigate(`/admin/relays/${relayId()}`)
} catch (e) {
@@ -79,6 +90,8 @@ export default function AdminRelayEdit() {
plan={plan()}
setPlan={setPlan}
plans={RELAY_PLAN_IDS}
config={config()}
setConfig={setConfig}
onSubmit={handleSubmit}
submitting={submitting()}
error={error()}
+3 -6
View File
@@ -3,6 +3,7 @@ import { createResource, createSignal, Show } from "solid-js"
import { deactivateTenantRelay, getTenantRelay } from "../../lib/api"
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"
@@ -41,13 +42,9 @@ export default function RelayDetail() {
/>
<Show when={!loading() && relay()}>
{(loadedRelay) => (
{(r) => (
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 py-2">{loadedRelay().name}</h1>
<p class="mt-1 text-sm text-gray-500">https://{loadedRelay().subdomain}.spaces.coracle.social</p>
<Show when={loadedRelay().description.trim()}>
<p class="mt-3 text-gray-700">{loadedRelay().description}</p>
</Show>
<RelayDetailCard relay={r()} />
</div>
)}
</Show>
+14 -1
View File
@@ -1,6 +1,6 @@
import { useNavigate, useParams } from "@solidjs/router"
import { Show, createEffect, createResource, createSignal } from "solid-js"
import { getTenantRelay, updateTenantRelay } from "../../lib/api"
import { getTenantRelay, updateTenantRelay, type RelayConfig } from "../../lib/api"
import RelayForm from "../../components/RelayForm"
import { RELAY_PLAN_IDS, type RelayPlanId } from "../../lib/relayPlans"
import { slugify } from "../../lib/slugify"
@@ -9,6 +9,14 @@ import PageContainer from "../../components/PageContainer"
import ResourceState from "../../components/ResourceState"
import useMinLoading from "../../components/useMinLoading"
const DEFAULT_CONFIG: RelayConfig = {
policy: { public_join: false, strip_signatures: false },
groups: { enabled: false, auto_join: false },
management: { enabled: false },
blossom: { enabled: false },
push: { enabled: false },
}
export default function RelayEdit() {
const navigate = useNavigate()
const params = useParams()
@@ -21,6 +29,7 @@ export default function RelayEdit() {
const [icon, setIcon] = createSignal("")
const [description, setDescription] = createSignal("")
const [plan, setPlan] = createSignal<RelayPlanId>("free")
const [config, setConfig] = createSignal<RelayConfig>(DEFAULT_CONFIG)
const [error, setError] = createSignal("")
const [submitting, setSubmitting] = createSignal(false)
@@ -32,6 +41,7 @@ export default function RelayEdit() {
setIcon(data.icon)
setDescription(data.description)
setPlan(RELAY_PLAN_IDS.includes(data.plan as RelayPlanId) ? (data.plan as RelayPlanId) : "free")
setConfig(data.config ?? DEFAULT_CONFIG)
})
async function handleSubmit(e: Event) {
@@ -45,6 +55,7 @@ export default function RelayEdit() {
icon: icon().trim(),
description: description().trim(),
plan: plan(),
config: config(),
})
navigate(`/relays/${relayId()}`)
} catch (e) {
@@ -79,6 +90,8 @@ export default function RelayEdit() {
plan={plan()}
setPlan={setPlan}
plans={RELAY_PLAN_IDS}
config={config()}
setConfig={setConfig}
onSubmit={handleSubmit}
submitting={submitting()}
error={error()}