Update readme, move frontend build to build phase in dockerfile

This commit is contained in:
Jon Staab
2026-06-02 10:50:30 -07:00
parent 430f33383b
commit b331a806ca
16 changed files with 371 additions and 38 deletions
+4
View File
@@ -14,6 +14,8 @@ import AdminTenantDetail from "@/pages/admin/AdminTenantDetail"
import AdminRelayList from "@/pages/admin/AdminRelayList"
import AdminRelayDetail from "@/pages/admin/AdminRelayDetail"
import AdminRelayEdit from "@/pages/admin/AdminRelayEdit"
import AdminInvoiceList from "@/pages/admin/AdminInvoiceList"
import AdminInvoiceDetail from "@/pages/admin/AdminInvoiceDetail"
import { account, eventStore, identity, pool } from "@/lib/state"
import { NostrProvider } from "@/lib/nostr"
@@ -72,6 +74,8 @@ export default function App() {
<Route path="/admin/relays" component={requireAdmin(AdminRelayList)} />
<Route path="/admin/relays/:id" component={requireAdmin(AdminRelayDetail)} />
<Route path="/admin/relays/:id/edit" component={requireAdmin(AdminRelayEdit)} />
<Route path="/admin/invoices" component={requireAdmin(AdminInvoiceList)} />
<Route path="/admin/invoices/:id" component={requireAdmin(AdminInvoiceDetail)} />
</Router>
</NostrProvider>
)
@@ -0,0 +1,55 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import { invoiceStatus, type Invoice } from "@/lib/api"
import { useProfileMetadata } from "@/lib/hooks"
import { formatPeriod, formatUsd } from "@/lib/format"
import { shortenPubkey } from "@/lib/pubkey"
const invoiceStatusStyles: Record<string, string> = {
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
paid: "bg-green-50 text-green-700 border-green-200",
void: "bg-gray-100 text-gray-500 border-gray-200",
}
type AdminInvoiceListItemProps = {
invoice: Invoice
href: string
}
export default function AdminInvoiceListItem(props: AdminInvoiceListItemProps) {
// Resolve the owning tenant's profile from the event store. AdminInvoiceList
// primes these profiles in one batch, so this subscription does not prime.
const metadata = useProfileMetadata(() => props.invoice.tenant_pubkey, { prime: false })
const status = () => invoiceStatus(props.invoice)
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 class="min-w-0">
<p class="font-medium text-gray-900">{formatUsd(props.invoice.amount)}</p>
<p class="text-xs text-gray-500">{formatPeriod(props.invoice.period_start, props.invoice.period_end)}</p>
<div class="mt-1.5 flex items-center gap-2">
<Show
when={getProfilePicture(metadata())}
fallback={
<div class="h-5 w-5 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{((metadata()?.name || metadata()?.display_name) || props.invoice.tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={getProfilePicture(metadata())} alt="" class="h-5 w-5 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="text-xs text-gray-500 truncate">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.invoice.tenant_pubkey)}</span>
</div>
</div>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"}`}>
{status()}
</span>
</div>
</A>
</li>
)
}
+1
View File
@@ -47,6 +47,7 @@ export default function AppShell(props: { children?: any }) {
const adminResources = [
{ href: "/admin/tenants", label: "Tenants" },
{ href: "/admin/relays", label: "Relays" },
{ href: "/admin/invoices", label: "Invoices" },
]
const navItemClass = (href: string) => {
@@ -0,0 +1,68 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import { invoiceStatus, type Invoice } from "@/lib/api"
import Field from "@/components/Field"
import { useProfileMetadata } from "@/lib/hooks"
import { formatPeriod, formatUsd } from "@/lib/format"
import { shortenPubkey } from "@/lib/pubkey"
const invoiceStatusStyles: Record<string, string> = {
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
paid: "bg-green-50 text-green-700 border-green-200",
void: "bg-gray-100 text-gray-500 border-gray-200",
}
type InvoiceDetailCardProps = {
invoice: Invoice
}
export default function InvoiceDetailCard(props: InvoiceDetailCardProps) {
// Resolve the owning tenant's profile so the Tenant field can show a name and
// avatar instead of a raw pubkey, like the admin list item.
const metadata = useProfileMetadata(() => props.invoice.tenant_pubkey, { prime: false })
const status = () => invoiceStatus(props.invoice)
return (
<div class="bg-white border border-gray-200 rounded-xl p-6 space-y-6">
<div class="flex items-center justify-between gap-3">
<p class="text-2xl font-bold text-gray-900">{formatUsd(props.invoice.amount)}</p>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"}`}>
{status()}
</span>
</div>
<hr class="border-gray-200" />
<dl class="grid gap-x-6 gap-y-4 sm:grid-cols-2">
<Field label="Billing period">
<span class="text-gray-900">{formatPeriod(props.invoice.period_start, props.invoice.period_end)}</span>
</Field>
<Field label="Created">
<span class="text-gray-900">{new Date(props.invoice.created_at * 1000).toLocaleDateString()}</span>
</Field>
<Show when={props.invoice.method}>
<Field label="Payment method">
<span class="uppercase text-gray-900">{props.invoice.method}</span>
</Field>
</Show>
<Field label="Tenant">
<A href={`/admin/tenants/${props.invoice.tenant_pubkey}`} class="group flex w-fit items-center gap-2 min-w-0">
<Show
when={getProfilePicture(metadata())}
fallback={
<div class="h-5 w-5 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{((metadata()?.name || metadata()?.display_name) || props.invoice.tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={getProfilePicture(metadata())} alt="" class="h-5 w-5 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="truncate text-blue-600 group-hover:underline">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.invoice.tenant_pubkey)}</span>
</A>
</Field>
</dl>
</div>
)
}
+4
View File
@@ -285,6 +285,10 @@ export function listRelays() {
return callApi<undefined, Relay[]>("GET", "/relays")
}
export function listInvoices() {
return callApi<undefined, Invoice[]>("GET", "/invoices")
}
export function getRelay(id: string) {
return callApi<undefined, Relay>("GET", `/relays/${id}`)
}
+6
View File
@@ -10,9 +10,11 @@ import {
createRelay,
deactivateRelay,
reactivateRelay,
getInvoice,
getRelay,
getTenant,
invoiceStatus,
listInvoices,
listRelayActivity,
listRelays,
listTenantInvoices,
@@ -145,6 +147,10 @@ export const useAdminTenants = () => createResource(listTenants)
export const useAdminRelays = () => createResource(listRelays)
export const useAdminInvoices = () => createResource(listInvoices)
export const useInvoice = (id: () => string) => createResource(id, getInvoice)
export const useAdminTenant = (pubkey: () => string) => createResource(pubkey, getTenant)
export const useAdminTenantRelays = (pubkey: () => string) => createResource(pubkey, listTenantRelays)
+1 -1
View File
@@ -25,7 +25,7 @@ export type EventSigner = {
signEvent(event: UnsignedEvent): Promise<SignedEvent>
}
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME || "Caravel"
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
export const eventStore = new EventStore()
export const pool = new RelayPool()
@@ -0,0 +1,43 @@
import { useParams } from "@solidjs/router"
import { createResource, Show } from "solid-js"
import BackLink from "@/components/BackLink"
import InvoiceDetailCard from "@/components/InvoiceDetailCard"
import InvoiceItemsList from "@/components/payment/InvoiceItemsList"
import PageContainer from "@/components/PageContainer"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/lib/useMinLoading"
import { listInvoiceItems } from "@/lib/api"
import { useInvoice } from "@/lib/hooks"
export default function AdminInvoiceDetail() {
const params = useParams()
const invoiceId = () => params.id ?? ""
const [invoice] = useInvoice(invoiceId)
const [items] = createResource(invoiceId, async (id) => {
if (!id) return []
try {
return await listInvoiceItems(id)
} catch {
return []
}
})
const loading = useMinLoading(() => invoice.loading && !invoice())
return (
<PageContainer>
<BackLink href="/admin/invoices" label="Invoices" />
<ResourceState loading={loading()} error={invoice.error} loadingText="Loading invoice..." errorText="Failed to load invoice." class="mb-4" />
<Show when={!loading() && invoice()}>
{(i) => (
<div class="space-y-6 mb-6">
<InvoiceDetailCard invoice={i()} />
<Show when={(items()?.length ?? 0) > 0}>
<InvoiceItemsList items={items() ?? []} />
</Show>
</div>
)}
</Show>
</PageContainer>
)
}
@@ -0,0 +1,49 @@
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import { fuzzySearch } from "@/lib/search"
import AdminInvoiceListItem from "@/components/AdminInvoiceListItem"
import PageContainer from "@/components/PageContainer"
import ResourceState from "@/components/ResourceState"
import SearchInput from "@/components/SearchInput"
import useMinLoading from "@/lib/useMinLoading"
import { primeProfiles, useAdminInvoices } from "@/lib/hooks"
export default function AdminInvoiceList() {
const [query, setQuery] = createSignal("")
const [invoices] = useAdminInvoices()
const loading = useMinLoading(() => invoices.loading)
// Each list item shows its tenant's profile; prime them all in one batch so
// we don't open a separate outbox subscription per invoice.
createEffect(() => {
const list = invoices() ?? []
if (!list.length) return
const sub = primeProfiles(list.map(i => i.tenant_pubkey))
onCleanup(() => sub.unsubscribe())
})
const filtered = createMemo(() => {
const list = invoices() ?? []
const q = query().trim()
if (!q) return list
return fuzzySearch(list, ["tenant_pubkey", "id"], q)
})
return (
<PageContainer>
<h1 class="text-2xl font-bold text-gray-900 mb-6 py-2">Invoices</h1>
<div class="mb-6">
<SearchInput value={query()} onInput={setQuery} placeholder="Search invoices..." />
</div>
<ResourceState loading={loading()} error={invoices.error} loadingText="Loading invoices..." errorText="Failed to load invoices." />
<Show when={!loading()}>
<Show when={filtered().length > 0} fallback={<p class="py-20 text-center text-gray-500">No invoices found.</p>}>
<ul class="space-y-3">
<For each={filtered()}>
{(invoice) => <AdminInvoiceListItem invoice={invoice} href={`/admin/invoices/${invoice.id}`} />}
</For>
</ul>
</Show>
</Show>
</PageContainer>
)
}