Add frontend spec

This commit is contained in:
Jon Staab
2026-03-26 13:49:46 -07:00
parent 9f737a25cd
commit a2239cf20e
17 changed files with 609 additions and 4 deletions
+74
View File
@@ -0,0 +1,74 @@
# Components
This file defines the spec components used across `frontend/spec/pages/*.md`.
- **page**: Root container for a route-level screen. Carries route metadata like `path`, auth requirements, and layout shell.
- **nav**: Top navigation area for primary brand and session-aware links.
- **header**: Top content block for a page or section, usually containing a title.
- **section**: Logical grouping of related content within a page.
- **hero**: Prominent introductory content block for a page.
- **heading**: Section heading wrapper that pairs a title with optional supporting copy.
- **title**: Primary label text for a page, section, card, or control.
- **description**: Supporting explanatory text under a title or heading.
- **text**: Generic body text when no more specific semantic component applies.
- **help_text**: Small informational guidance text, usually near forms or auth flows.
- **brand**: Product identity block (name/logo).
- **badge**: Small status/highlight label.
- **status**: Current state indicator for an entity (for example relay status).
- **meta**: Secondary metadata text (IDs, pubkeys, auxiliary attributes).
- **copyright**: Footer legal ownership line.
- **link**: Navigational text/action leading to another route or URL.
- **links**: Group of related link components.
- **back_link**: Explicit “go back” navigation link to a parent/list view.
- **back_button**: Button-style backward navigation within a multi-step screen.
- **button**: Generic clickable action control.
- **submit_button**: Primary form submission control with optional loading label.
- **actions**: Container for action controls associated with a section/card.
- **controls**: Container for filters/search/sort controls.
- **tabs**: Container for tabbed navigation controls.
- **tab**: A selectable tab option within `tabs`.
- **screen**: Named sub-view/state within a page (e.g., a login subflow).
- **modal**: Overlay dialog for focused temporary interaction.
- **form**: Form container for user input and submission behavior.
- **field**: Structured form field abstraction (label + input type + validation intent).
- **input**: Single input control for user-entered values.
- **select**: Dropdown control for choosing one option from many.
- **option**: A selectable value within `select` or plan/method groups.
- **copyable_input**: Read-only input-like field designed for quick copy actions.
- **tooltip_error**: Inline or anchored validation error shown near a specific field.
- **error_message**: Message shown when an action fails.
- **error_state**: Full-state error presentation for failed data loading.
- **loading_state**: Full-state loading presentation while data is being fetched.
- **list**: Repeating collection container.
- **item**: Single element within a repeating collection.
- **grid**: Multi-column layout container for repeated items/cards.
- **card**: Bounded content block used in feature grids and summaries.
- **product**: Specialized card describing an external ecosystem product/integration.
- **relay_detail_card**: Composite card summarizing relay properties and management actions.
- **avatar**: User/profile image representation.
- **bullets**: Container for short point-form highlights.
- **toggle**: On/off control for a boolean feature or policy.
- **toggles**: Group of related boolean toggle controls.
- **plan_selector**: Control used to switch between available relay plans.
- **pricing_table**: Plan comparison and pricing presentation block.
- **member_count**: Display of current member usage for a relay.
- **members**: Plan capacity descriptor (member limits).
- **price**: Plan pricing descriptor.
- **tenant**: Tenant identity/value display in admin-oriented contexts.
- **created_at**: Human-readable creation timestamp display.
- **bolt11**: Lightning invoice string display.
- **qr_code**: Visual QR representation of a connection URI.
- **camera_preview**: Live camera viewport used for scanning QR codes.
## Control directives used in specs
These appear in the Pug-like spec language but are flow directives rather than UI components:
- **if / else**: Conditional branch based on a boolean condition.
- **when**: Conditional rendering keyed off a selected tab/state/value.
+11
View File
@@ -0,0 +1,11 @@
The frontend is a marketing site combined with a tenant and admin dashboard, both of which depend on the backend's api. Tenants are able to provision new relays, manage existing relays, and deactivate their relays. They can also manage billing information and service level. Admins are able to do this as well, but have an additional admin dashboard where they can view and manage all tenants and relays.
- See ./lib.md for a list of utilities
- See ./pages.md for descriptions of each page
- See ./components.md for a list of reusable components
The frontend implementation's file structure and contents should match the spec.
Dependencies:
- `applesauce` for all nostr-related functionality
+25
View File
@@ -0,0 +1,25 @@
The api allows the frontend to access the database. Most endpoints are authenticated using NIP 98.
## `class ApiError`
- This is a custom error class with an additional `status` property
## `function makeAuth<T>()`
- If the user is not currently logged in, returns undefined
- Otherwise, builds an NIP 98 auth header with no `method` and `u` set to `VITE_API_URL`. This is non-standard, but intentional in order to prevent repeated signer authorizations.
- This function is memoized over pubkey and expires after 10 minutes.
- Returns the full `Authorization` header value.
## `function callApi<T>(method: string, path: string, body?: T)`
- Uses `makeAuth` to obtain a NIP 98 authorization header.
- Calls the backend api and returns the decoded json or throws an `ApiError`.
## `function get(path: string)`
- Calls `callApi` with `get`
## `function post<T>(path: string, body: T)`
- Calls `callApi` with `post` and body
+85
View File
@@ -0,0 +1,85 @@
Nostr integration layer for the frontend. It initializes shared Nostr primitives (event store, relay pool, account manager), wires signer transport, persists local account state, and exposes reactive helpers for auth/account/profile data.
## Constants
### `API_URL`
- Reads `VITE_API_URL` from environment.
### `PLATFORM_NAME`
- Reads `VITE_PLATFORM_NAME` from environment.
- Falls back to `"Caravel"`.
### Storage keys
- `caravel.accounts`: serialized account list.
- `caravel.activeAccount`: currently selected account id.
## Shared singletons
### `eventStore`
- Global `EventStore` used to cache and observe Nostr events (profiles, relay lists, etc).
### `pool`
- Global `RelayPool` used for relay connections, subscriptions, and publishing.
### `accounts`
- Global `AccountManager` that holds all known local accounts and active account state.
## Startup wiring
- Registers common account types with `registerCommonAccountTypes(accounts)`.
- Configures event loading via `createEventLoaderForStore(eventStore, pool, ...)` with lookup and extra relay seeds.
- Binds `NostrConnectSigner` network methods to the app relay pool:
- `subscriptionMethod = pool.subscription.bind(pool)`
- `publishMethod = pool.publish.bind(pool)`
## `function restoreAccounts()`
- Restores serialized accounts from localStorage.
- Ignores corrupted local data safely (catch-and-continue).
- Restores the active account id if it still exists in the restored account set.
## `function activateAccount(account: IAccount)`
- Adds the account to `accounts`.
- Sets it as active.
- Persists account state to localStorage via `persistAccounts()`.
## `function persistAccounts()`
- Serializes all accounts into `caravel.accounts`.
- Persists active account id to `caravel.activeAccount`.
- Removes `caravel.activeAccount` if no active account exists.
## `function useActiveAccount()`
- Solid helper that returns a reactive signal for current active account.
- Subscribes to `accounts.active$` and cleans up the subscription automatically.
## `function useProfilePicture(pubkey: () => string | undefined)`
- Reactive helper to resolve a user profile picture URL from a pubkey.
- If pubkey is missing, clears picture to `undefined`.
- Subscribes to profile updates in `eventStore` and maps profile to a picture via `getProfilePicture`.
- Calls `primeProfiles([pubkey])` to proactively fetch profile data.
- Cleans up both profile and network subscriptions when dependencies change/unmount.
## `function primeProfiles(pubkeys: string[])`
- Preloads Nostr profile events (`kind: 0`) for a set of pubkeys.
- Deduplicates and filters invalid pubkeys.
- Early-returns a no-op unsubscribable when no pubkeys are provided.
- Uses currently connected relays as seeds (`pool.relays.keys()`).
- Optionally fetches relay list events (`kind: 10002`) from seed relays to improve mailbox/outbox routing.
- Builds optimized relay routing by:
- including mailbox pointers from event store,
- applying fallback relays when needed,
- selecting optimal relays with connection limits,
- converting to an outbox map.
- Opens an outbox subscription for profile events and stores incoming events in `eventStore` (ignoring `EOSE`).
- Returns an object with `unsubscribe()` that tears down both profile and mailbox-seed subscriptions.
+29
View File
@@ -0,0 +1,29 @@
# Account
The account page lets an authenticated tenant manage billing settings and review invoice history.
```pug
page(path="/account", auth="required", shell="app")
header
title My Account
section(id="status")
heading Account Status
badge tenant
section(id="billing")
heading Recurring Billing
description Enable automatic payments by providing your Nostr Wallet Connect URL.
input(name="nwc_url", placeholder="nostr+walletconnect://...")
button Save
error_message(on="save_failure")
section(id="invoices")
heading Invoice History
loading_state(message="Loading invoices...")
list(empty="No invoices yet")
item
status
created_at
bolt11
```
+25
View File
@@ -0,0 +1,25 @@
# Admin Relay Detail
The admin relay detail page exposes relay state and controls with admin-level editing and deactivation.
```pug
page(path="/admin/relays/:id", auth="required", role="admin", shell="app")
back_link(href="/admin/relays", label="Relays")
loading_state(message="Loading relay...")
error_state(message="Failed to load relay.")
relay_detail_card(edit_href="/admin/relays/:id/edit", show_tenant=true, enforce_plan_limits=false, show_plan_actions=false)
actions
button(action="admin_deactivate_relay") Deactivate
toggles
toggle(name="policy_public_join") Public join
toggle(name="policy_strip_signatures") Strip signatures
toggle(name="groups_enabled") Groups
toggle(name="management_enabled") Management API
toggle(name="blossom_enabled") Media storage
toggle(name="livekit_enabled") LiveKit support
toggle(name="push_enabled") Push notifications
error_message(on="mutation_failure")
```
+21
View File
@@ -0,0 +1,21 @@
# Admin Relay Edit
The admin relay edit page updates relay metadata and returns to the admin relay detail view.
```pug
page(path="/admin/relays/:id/edit", auth="required", role="admin", shell="app")
back_link(href="/admin/relays/:id", label="Back")
header
title Edit Relay (Admin)
loading_state(message="Loading relay...")
error_state(message="Failed to load relay.")
form(action="admin_update_relay")
field(name="name", label="Relay Name", required=true)
field(name="subdomain", label="Subdomain", required=true)
field(name="icon", label="Icon URL", type="url")
field(name="description", label="Description", type="textarea")
submit_button(default="Save Changes", loading="Saving...")
error_message(on="submit_failure")
```
+22
View File
@@ -0,0 +1,22 @@
# Admin Relay List
The admin relay list page shows all relays with search for operators.
```pug
page(path="/admin/relays", auth="required", role="admin", shell="app")
header
title Relays
controls
input(type="search", name="query", placeholder="Search relays...")
loading_state(message="Loading relays...")
error_state(message="Failed to load relays.")
list(empty="No relays found")
item(link="/admin/relays/:id")
title relay.info_name || relay.subdomain
subtitle {relay.subdomain}.spaces.coracle.social
tenant relay.tenant
status relay.status
```
@@ -0,0 +1,25 @@
# Admin Tenant Detail
The admin tenant detail page shows tenant status and all relays for a selected tenant.
```pug
page(path="/admin/tenants/:id", auth="required", role="admin", shell="app")
back_link(href="/admin/tenants", label="Tenants")
header
title Tenant :id
loading_state(message="Loading tenant...")
error_state(message="Failed to load tenant.")
section(id="status")
heading Status
text Current: tenant
section(id="relays")
heading Relays
list(empty="No relays")
item(link="/admin/relays/:id")
title relay.info_name || relay.subdomain
subtitle {relay.subdomain}.spaces.coracle.social
status relay.status
```
+23
View File
@@ -0,0 +1,23 @@
# Admin Tenant List
The admin tenant list page shows all tenants with profile-enriched search results.
```pug
page(path="/admin/tenants", auth="required", role="admin", shell="app")
header
title Tenants
controls
input(type="search", name="query", placeholder="Search tenants...")
loading_state(message="Loading tenants...")
error_state(message="Failed to load tenants.")
list(empty="No tenants found")
item(link="/admin/tenants/:pubkey")
avatar(profile.picture)
title profile.name || shortened_pubkey
subtitle profile.about || "No profile bio"
meta tenant.pubkey
badge tenant
```
+86
View File
@@ -0,0 +1,86 @@
# Home
The home page is a marketing page with CTAs to create a relay or log in (if the user isn't logged in). If the user is logged in, "Sign in" actions are replaced with "Dashboard" and route to `/relays`.
```pug
page(path="/")
nav(sticky=true)
brand(name="Caravel", logo="/caravel.png")
if(authenticated)
link(href="/relays") Dashboard
else
link(href="/login") Sign in
section(id="hero")
badge Nostr-native relay hosting
hero
title Your community, your relay.
description Spin up private, managed infrastructure for your community in minutes. Full control over membership, access, and policies — no DevOps required.
actions
button(variant="primary", href="/relays/new") Get started free
if(authenticated)
button(variant="neutral", href="/relays") Dashboard
else
button(variant="neutral", href="/login") Sign in
section(id="features")
heading
title Everything you need
description Caravel takes care of the infrastructure so you can focus on building your community.
grid(columns=3)
card(icon="hosting")
title Managed hosting
description We handle uptime, backups, and updates. Your relay stays online so your community never misses a beat.
card(icon="membership")
title Membership control
description Approve members with Nostr pubkeys. Keep your space invite-only or open — you decide.
card(icon="shield")
title Access policies
description Fine-grained write and read permissions. Moderate content without touching any server config.
card(icon="storage")
title Blossom storage
description Attach media files to your relay with integrated Blossom server support on paid plans.
card(icon="video")
title LiveKit video
description Built-in video room support via LiveKit. Host voice and video calls directly within your community.
card(icon="lightning")
title Pay with sats
description Lightning-native billing. No credit cards, no bank accounts — just sats, straight from your wallet.
section(id="connect")
heading
title Connect with your community
description Once your relay is live, these Nostr-native platforms let your members connect, chat, and collaborate — all powered by your relay.
grid(columns=2)
product(name="Flotilla", href="https://flotilla.social", domain="flotilla.social")
description A community platform built on Nostr. Flotilla gives your members channels, threads, and a rich social experience — all connected to your relay.
bullets
item Nostr-native channels & threads
item Works directly with your relay
item Open source & self-sovereign
product(name="Chachi", href="https://chachi.chat", domain="chachi.chat")
description A group chat app built on top of Nostr. Chachi makes it easy for your community to have real-time conversations, all flowing through your own relay.
bullets
item Real-time group messaging
item Bring your own relay
item No accounts — just your Nostr key
section(id="pricing")
heading
title Simple pricing
description Pay in sats. Upgrade or cancel any time.
pricing_table(cta_href="/relays/new")
section(id="cta")
heading
title Ready to launch your relay?
description Join communities already running on Caravel. Set up in minutes, pay in sats.
button(variant="primary", href="/relays/new") Create your relay
footer
brand(name="Caravel", logo="/caravel.png")
copyright © {currentYear} Caravel. Built on Nostr.
links
link(href="https://flotilla.social") Flotilla
link(href="https://chachi.chat") Chachi
```
+67
View File
@@ -0,0 +1,67 @@
# Login
The login page authenticates users with Nostr via extension, remote signer (NIP-46), or key material, then redirects to `/relays`.
```pug
page(path="/login")
hero
badge Secure Nostr Login
title Welcome back
description Connect your Nostr account to manage relay hosting, billing, and access in one place.
bullets
item Own your identity with cryptographic sign-in.
item No passwords or email required.
item Get fast access to your relays.
auth_card
section(id="method_select")
title Log in / Sign up
description Use any Nostr signer method. New users are automatically onboarded.
tabs
tab(id="nip07") Extension
tab(id="nip46") Signer
tab(id="key") Key
when(tab="nip07")
button Continue with extension
when(tab="nip46")
button Continue with signer
when(tab="key")
button Continue with key
screen(id="nip46")
back_button Back
title Log in with signer
tabs
tab(id="qr") Use QR Code
tab(id="paste") Paste Link
when(tab="qr")
qr_code
copyable_input(name="nostrconnect_uri")
when(tab="paste")
input(name="bunker_url", placeholder="bunker://...")
button Scan QR code
button Connect to Signer
screen(id="key")
back_button Back
title Log in with key
tabs
tab(id="plaintext") Plaintext
tab(id="encrypted") Encrypted
when(tab="plaintext")
input(name="nsec", placeholder="nsec1...")
when(tab="encrypted")
input(name="ncryptsec", placeholder="ncryptsec1...")
input(type="password", name="password", placeholder="Password")
button Log in
error_message
help_text Having trouble? Make sure your signer is unlocked and connected.
modal(id="scanner")
title Scan QR Code
camera_preview
```
+27
View File
@@ -0,0 +1,27 @@
# Relay Detail
The relay detail page shows relay status and settings for an authenticated tenant, with inline toggles and plan changes.
```pug
page(path="/relays/:id", auth="required", shell="app")
back_link(href="/relays", label="Relays")
loading_state(message="Loading relay...")
error_state(message="Failed to load relay.")
relay_detail_card(edit_href="/relays/:id/edit")
actions
button(action="deactivate_relay") Deactivate
toggles
toggle(name="policy_public_join") Public join
toggle(name="policy_strip_signatures") Strip signatures
toggle(name="groups_enabled") Groups
toggle(name="management_enabled") Management API
toggle(name="blossom_enabled") Media storage
toggle(name="livekit_enabled") LiveKit support
toggle(name="push_enabled") Push notifications
plan_selector(action="update_plan")
member_count(source="relay_url")
error_message(on="mutation_failure")
```
+21
View File
@@ -0,0 +1,21 @@
# Relay Edit
The relay edit page lets an authenticated tenant update relay metadata and returns to relay detail on success.
```pug
page(path="/relays/:id/edit", auth="required", shell="app")
back_link(href="/relays/:id", label="Back")
header
title Edit Relay
loading_state(message="Loading relay...")
error_state(message="Failed to load relay.")
form(action="update_tenant_relay")
field(name="name", label="Relay Name", required=true)
field(name="subdomain", label="Subdomain", required=true)
field(name="icon", label="Icon URL", type="url")
field(name="description", label="Description", type="textarea")
submit_button(default="Save Changes", loading="Saving...")
error_message(on="submit_failure")
```
+29
View File
@@ -0,0 +1,29 @@
# Relay List
The relay list page shows an authenticated tenant's relays with search and status filtering.
```pug
page(path="/relays", auth="required", shell="app")
header
title My Relays
button(href="/relays/new") Add Relay
controls
input(type="search", name="query", placeholder="Search by name or subdomain")
select(name="status")
option(value="all") All statuses
option(value="active") Active
option(value="pending") Pending
option(value="deactivated") Deactivated
option(value="provisioning_failed") Provisioning failed
option(value="suspended") Suspended
loading_state(message="Loading relays...")
error_state(message="Failed to load relays.")
list(empty="No relays found")
item(link="/relays/:id")
title relay.info_name || relay.subdomain
subtitle https://{relay.subdomain}.spaces.coracle.social
status relay.status
```
+32
View File
@@ -0,0 +1,32 @@
# Relay New
The new relay page lets an authenticated tenant configure and create a relay, then navigates to its detail page.
```pug
page(path="/relays/new", auth="required", shell="app")
header
title New Relay
form(action="create_tenant_relay")
field(name="name", label="Relay Name", required=true, placeholder="My Community")
field(name="subdomain", label="Subdomain", required=true, suffix=".spaces.coracle.social", placeholder="my-community")
field(name="icon", label="Icon URL", type="url", placeholder="https://example.com/icon.png")
field(name="description", label="Description", type="textarea", placeholder="A community for...")
field(name="plan", label="Plan")
option(value="free")
title Free
price Free
members Up to 10 members
option(value="basic")
title Basic
price 10,000 sats/mo
members Up to 100 members
option(value="growth")
title Growth
price 50,000 sats/mo
members Unlimited members
tooltip_error(field="subdomain")
submit_button(default="Create Relay", loading="Creating...")
```
+7 -4
View File
@@ -1,5 +1,6 @@
import { A } from "@solidjs/router"
import PricingTable from "../components/PricingTable"
import { useActiveAccount } from "../lib/nostr"
function CheckIcon() {
return (
@@ -20,6 +21,8 @@ function ExternalLinkIcon() {
}
export default function Home() {
const account = useActiveAccount()
return (
<div class="min-h-screen bg-white text-gray-900 overflow-x-hidden">
@@ -31,10 +34,10 @@ export default function Home() {
Caravel
</div>
<A
href="/login"
href={account() ? "/relays" : "/login"}
class="text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
>
Sign in
{account() ? "Dashboard" : "Sign in"}
</A>
</div>
</nav>
@@ -70,10 +73,10 @@ export default function Home() {
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
</A>
<A
href="/login"
href={account() ? "/relays" : "/login"}
class="inline-flex items-center gap-2 py-3 px-8 border border-gray-200 text-gray-700 font-semibold rounded-xl hover:bg-gray-50 transition-all"
>
Sign in
{account() ? "Dashboard" : "Sign in"}
</A>
</div>
</div>