diff --git a/backend/src/api.rs b/backend/src/api.rs index f8204b5..3203f9c 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -321,7 +321,6 @@ struct UpdateRelayRequest { subdomain: String, icon: String, description: String, - plan: String, config: Option, } @@ -363,7 +362,7 @@ async fn update_tenant_relay( subdomain: payload.subdomain.clone(), icon: payload.icon, description: payload.description, - plan: payload.plan, + plan: existing.plan, status: existing.status, config: payload.config, }; @@ -760,7 +759,7 @@ async fn admin_update_relay( subdomain: payload.subdomain.clone(), icon: payload.icon, description: payload.description, - plan: payload.plan, + plan: existing.plan, status: existing.status, config: payload.config, }; diff --git a/frontend/src/assets/menu-dots-2.svg b/frontend/src/assets/menu-dots-2.svg new file mode 100644 index 0000000..604381c --- /dev/null +++ b/frontend/src/assets/menu-dots-2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/components/RelayDetailCard.tsx b/frontend/src/components/RelayDetailCard.tsx index 84208a6..c6c9746 100644 --- a/frontend/src/components/RelayDetailCard.tsx +++ b/frontend/src/components/RelayDetailCard.tsx @@ -1,5 +1,7 @@ -import { For, Show } from "solid-js" +import { A } from "@solidjs/router" +import { Show, createEffect, createSignal, onCleanup } from "solid-js" import type { Relay } from "../lib/api" +import menuDotsIcon from "../assets/menu-dots-2.svg" function Field(props: { label: string; children: any }) { return ( @@ -10,20 +12,60 @@ function Field(props: { label: string; children: any }) { ) } -function Badge(props: { on: boolean; onLabel?: string; offLabel?: string }) { - const label = () => (props.on ? (props.onLabel ?? "Yes") : (props.offLabel ?? "No")) +function ToggleField(props: { label: string; children: any }) { return ( - - {label()} - +
+
{props.label}
+
{props.children}
+
) } -function Section(props: { title: string; children: any }) { +function ToggleButton(props: { + enabled: boolean + disabled?: boolean + onToggle?: () => void + onLabel?: string + offLabel?: string +}) { + const label = () => (props.enabled ? (props.onLabel ?? "Enabled") : (props.offLabel ?? "Disabled")) + + return ( +
+ + {label()} +
+ ) +} + +function DetailSection(props: { title: string; children: any }) { return (

{props.title}

-
+
+ {props.children} +
+
+ ) +} + +function MembershipSection(props: { title: string; children: any }) { + return ( +
+

{props.title}

+
{props.children}
@@ -32,98 +74,223 @@ function Section(props: { title: string; children: any }) { type RelayDetailCardProps = { relay: Relay + currentMembers?: number showTenant?: boolean + editHref?: string + onDeactivate?: () => void + deactivating?: boolean + onTogglePublicJoin?: () => void + onToggleStripSignatures?: () => void + onToggleGroups?: () => void + onToggleManagement?: () => void + onToggleMediaStorage?: () => void + onTogglePushNotifications?: () => void } export default function RelayDetailCard(props: RelayDetailCardProps) { const r = () => props.relay const cfg = () => r().config + const [menuOpen, setMenuOpen] = createSignal(false) + let menuContainerRef: HTMLDivElement | undefined + + const memberLimitByPlan: Record = { + free: "10", + basic: "100", + growth: "∞", + } + + const memberLimitLabel = () => memberLimitByPlan[r().plan] ?? "?" + const isTopTier = () => r().plan === "growth" + + createEffect(() => { + if (!menuOpen()) return + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node | null + if (target && !menuContainerRef?.contains(target)) { + setMenuOpen(false) + } + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setMenuOpen(false) + } + } + + document.addEventListener("mousedown", handleClickOutside) + document.addEventListener("keydown", handleEscape) + + onCleanup(() => { + document.removeEventListener("mousedown", handleClickOutside) + document.removeEventListener("keydown", handleEscape) + }) + }) return (
{/* Header */} -
- - - -
-

{r().name}

- - wss://{r().subdomain}.spaces.coracle.social - - -

{r().description}

+
+
+ + +
+

{r().name}

+ + wss://{r().subdomain}.spaces.coracle.social + + +

{r().description}

+
+
+ + +
+ + +
+ setMenuOpen(false)} + > + Edit Details + + +
+
+

- {/* Relay info */} -
- - {r().plan} + + + + + + + + + +
+ + + + + + + + + + + + + + Upgrade Plan + + } + > + + + + + + Upgrade Plan + + } + > + + + + + +
+ + + + {props.currentMembers ?? "—"} - - - {r().status} - + + {memberLimitLabel()} - {r().schema} {r().tenant} -
- -
- - {/* Config sections */} - -
- - + + + + Upgrade Plan + - - - -
- -
- -
- - - - - - -
- -
- -
- - - - -
- -
- -
- - - - - - -
-
+
+
) } diff --git a/frontend/src/components/RelayForm.tsx b/frontend/src/components/RelayForm.tsx index 34a1a9d..589dff4 100644 --- a/frontend/src/components/RelayForm.tsx +++ b/frontend/src/components/RelayForm.tsx @@ -1,6 +1,3 @@ -import type { RelayConfig } from "../lib/api" -import Checkbox from "./Checkbox" - type RelayFormProps = { name: string setName: (value: string) => void @@ -10,11 +7,6 @@ type RelayFormProps = { setIcon: (value: string) => void description: string setDescription: (value: string) => void - plan: string - setPlan: (value: string) => void - plans: readonly string[] - config: RelayConfig - setConfig: (value: RelayConfig) => void onSubmit: (e: Event) => void submitting: boolean error?: string @@ -23,10 +15,6 @@ type RelayFormProps = { } export default function RelayForm(props: RelayFormProps) { - function patchConfig(patch: Partial) { - props.setConfig({ ...props.config, ...patch }) - } - return (
{/* Basic info */} @@ -69,81 +57,6 @@ export default function RelayForm(props: RelayFormProps) { class="w-full border border-gray-300 rounded-lg px-3 py-2" />
-
- -
- {props.plans.map(p => ( - - ))} -
-
- - {/* Policy */} -
- Policy - patchConfig({ policy: { ...props.config.policy, public_join: v } })} - /> - patchConfig({ policy: { ...props.config.policy, strip_signatures: v } })} - /> -
- - {/* Groups (NIP 29) */} -
- Groups (NIP 29) - patchConfig({ groups: { ...props.config.groups, enabled: v } })} - /> - patchConfig({ groups: { ...props.config.groups, auto_join: v } })} - /> -
- - {/* Management (NIP 86) */} -
- Management (NIP 86) - patchConfig({ management: { enabled: v } })} - /> -
- - {/* Blossom */} -
- Blossom - patchConfig({ blossom: { enabled: v } })} - /> -
- - {/* Push (NIP 9a) */} -
- Push (NIP 9a) - patchConfig({ push: { enabled: v } })} - /> -
- {props.error &&

{props.error}

} -

{error()}

diff --git a/frontend/src/pages/admin/AdminRelayEdit.tsx b/frontend/src/pages/admin/AdminRelayEdit.tsx index d72e6b8..0d3d0aa 100644 --- a/frontend/src/pages/admin/AdminRelayEdit.tsx +++ b/frontend/src/pages/admin/AdminRelayEdit.tsx @@ -2,7 +2,6 @@ import { useNavigate, useParams } from "@solidjs/router" import { Show, createEffect, createResource, createSignal } from "solid-js" 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" import BackLink from "../../components/BackLink" import PageContainer from "../../components/PageContainer" @@ -28,8 +27,6 @@ export default function AdminRelayEdit() { const [subdomain, setSubdomain] = createSignal("") const [icon, setIcon] = createSignal("") const [description, setDescription] = createSignal("") - const [plan, setPlan] = createSignal("free") - const [config, setConfig] = createSignal(DEFAULT_CONFIG) const [error, setError] = createSignal("") const [submitting, setSubmitting] = createSignal(false) @@ -40,8 +37,6 @@ export default function AdminRelayEdit() { setSubdomain(data.subdomain) 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) { @@ -54,8 +49,7 @@ export default function AdminRelayEdit() { subdomain: slugify(subdomain()), icon: icon().trim(), description: description().trim(), - plan: plan(), - config: config(), + config: relay()?.config ?? DEFAULT_CONFIG, }) navigate(`/admin/relays/${relayId()}`) } catch (e) { @@ -87,11 +81,6 @@ export default function AdminRelayEdit() { setIcon={setIcon} description={description()} setDescription={setDescription} - plan={plan()} - setPlan={setPlan} - plans={RELAY_PLAN_IDS} - config={config()} - setConfig={setConfig} onSubmit={handleSubmit} submitting={submitting()} error={error()} diff --git a/frontend/src/pages/relays/RelayDetail.tsx b/frontend/src/pages/relays/RelayDetail.tsx index 673a03b..41a9af7 100644 --- a/frontend/src/pages/relays/RelayDetail.tsx +++ b/frontend/src/pages/relays/RelayDetail.tsx @@ -1,6 +1,6 @@ -import { useParams, A } from "@solidjs/router" -import { createResource, createSignal, Show } from "solid-js" -import { deactivateTenantRelay, getTenantRelay } from "../../lib/api" +import { useParams } from "@solidjs/router" +import { createMemo, createResource, createSignal, Show } from "solid-js" +import { deactivateTenantRelay, getRelayMemberCount, getTenantRelay, updateTenantRelay, type RelayConfig } from "../../lib/api" import BackLink from "../../components/BackLink" import PageContainer from "../../components/PageContainer" import RelayDetailCard from "../../components/RelayDetailCard" @@ -10,10 +10,15 @@ import useMinLoading from "../../components/useMinLoading" export default function RelayDetail() { const params = useParams() const relayId = () => params.id ?? "" - const [relay, { refetch }] = createResource(relayId, getTenantRelay) + const [relay, { refetch, mutate }] = createResource(relayId, getTenantRelay) + const relayUrl = createMemo(() => { + const subdomain = relay()?.subdomain + return subdomain ? `wss://${subdomain}.spaces.coracle.social` : undefined + }) + const [memberCount] = createResource(relayUrl, async (url) => getRelayMemberCount(url)) const [busy, setBusy] = createSignal(false) const [error, setError] = createSignal("") - const loading = useMinLoading(() => relay.loading) + const loading = useMinLoading(() => relay.loading && !relay()) async function handleDeactivate() { if (busy()) return @@ -29,6 +34,104 @@ export default function RelayDetail() { } } + function withDefaults(config?: RelayConfig): RelayConfig { + return { + policy: { + public_join: config?.policy.public_join ?? false, + strip_signatures: config?.policy.strip_signatures ?? false, + }, + groups: { + enabled: config?.groups.enabled ?? true, + auto_join: config?.groups.auto_join ?? true, + }, + management: { + enabled: config?.management.enabled ?? true, + }, + blossom: { + enabled: config?.blossom.enabled ?? (relay()?.plan !== "free"), + }, + push: { + enabled: config?.push.enabled ?? true, + }, + } + } + + async function updateFlags(nextConfig: RelayConfig, previousConfig: RelayConfig) { + const current = relay() + if (!current) return + + setError("") + mutate({ ...current, config: nextConfig }) + + try { + await updateTenantRelay(relayId(), { + name: current.name, + subdomain: current.subdomain, + icon: current.icon, + description: current.description, + config: nextConfig, + }) + await refetch() + } catch (e) { + mutate({ ...current, config: previousConfig }) + setError(e instanceof Error ? e.message : "Failed to update relay settings") + } + } + + function togglePublicJoin() { + const config = withDefaults(relay()?.config) + const nextConfig = { + ...config, + policy: { ...config.policy, public_join: !config.policy.public_join }, + } + void updateFlags(nextConfig, config) + } + + function toggleStripSignatures() { + const config = withDefaults(relay()?.config) + const nextConfig = { + ...config, + policy: { ...config.policy, strip_signatures: !config.policy.strip_signatures }, + } + void updateFlags(nextConfig, config) + } + + function toggleGroups() { + const config = withDefaults(relay()?.config) + const nextConfig = { + ...config, + groups: { ...config.groups, enabled: !config.groups.enabled }, + } + void updateFlags(nextConfig, config) + } + + function toggleManagement() { + const config = withDefaults(relay()?.config) + const nextConfig = { + ...config, + management: { enabled: !config.management.enabled }, + } + void updateFlags(nextConfig, config) + } + + function toggleMediaStorage() { + const config = withDefaults(relay()?.config) + const nextConfig = { + ...config, + blossom: { enabled: !config.blossom.enabled }, + } + void updateFlags(nextConfig, config) + } + + function togglePushNotifications() { + const config = withDefaults(relay()?.config) + const nextConfig = { + ...config, + push: { enabled: !config.push.enabled }, + } + void updateFlags(nextConfig, config) + } + return ( @@ -44,26 +147,22 @@ export default function RelayDetail() { {(r) => (
- +
)}
- -
- - Edit - - -

{error()}

diff --git a/frontend/src/pages/relays/RelayEdit.tsx b/frontend/src/pages/relays/RelayEdit.tsx index d887e36..1810dbe 100644 --- a/frontend/src/pages/relays/RelayEdit.tsx +++ b/frontend/src/pages/relays/RelayEdit.tsx @@ -2,7 +2,6 @@ import { useNavigate, useParams } from "@solidjs/router" import { Show, createEffect, createResource, createSignal } from "solid-js" 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" import BackLink from "../../components/BackLink" import PageContainer from "../../components/PageContainer" @@ -28,8 +27,6 @@ export default function RelayEdit() { const [subdomain, setSubdomain] = createSignal("") const [icon, setIcon] = createSignal("") const [description, setDescription] = createSignal("") - const [plan, setPlan] = createSignal("free") - const [config, setConfig] = createSignal(DEFAULT_CONFIG) const [error, setError] = createSignal("") const [submitting, setSubmitting] = createSignal(false) @@ -40,8 +37,6 @@ export default function RelayEdit() { setSubdomain(data.subdomain) 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) { @@ -54,8 +49,7 @@ export default function RelayEdit() { subdomain: slugify(subdomain()), icon: icon().trim(), description: description().trim(), - plan: plan(), - config: config(), + config: relay()?.config ?? DEFAULT_CONFIG, }) navigate(`/relays/${relayId()}`) } catch (e) { @@ -87,11 +81,6 @@ export default function RelayEdit() { setIcon={setIcon} description={description()} setDescription={setDescription} - plan={plan()} - setPlan={setPlan} - plans={RELAY_PLAN_IDS} - config={config()} - setConfig={setConfig} onSubmit={handleSubmit} submitting={submitting()} error={error()} diff --git a/frontend/src/pages/relays/RelayNew.tsx b/frontend/src/pages/relays/RelayNew.tsx index d14ff2f..96c1c7f 100644 --- a/frontend/src/pages/relays/RelayNew.tsx +++ b/frontend/src/pages/relays/RelayNew.tsx @@ -1,6 +1,6 @@ import { Show, createSignal } from "solid-js" import { useNavigate } from "@solidjs/router" -import { createTenantRelay } from "../../lib/api" +import { createTenantRelay, type RelayConfig } from "../../lib/api" import { slugify } from "../../lib/slugify" const PLANS = [ @@ -11,6 +11,14 @@ const PLANS = [ type PlanId = (typeof PLANS)[number]["id"] +const DEFAULT_CONFIG: RelayConfig = { + policy: { public_join: false, strip_signatures: false }, + groups: { enabled: true, auto_join: true }, + management: { enabled: true }, + blossom: { enabled: false }, + push: { enabled: true }, +} + export default function RelayNew() { const navigate = useNavigate() const [name, setName] = createSignal("") @@ -40,6 +48,10 @@ export default function RelayNew() { icon: icon().trim(), description: description().trim(), plan: plan(), + config: { + ...DEFAULT_CONFIG, + blossom: { enabled: plan() !== "free" }, + }, }) navigate(`/relays/${relay.id}`) } catch (e) {