223 lines
9.2 KiB
Go
223 lines
9.2 KiB
Go
// Package app holds the go-wasm-web example's pages and components as
|
||
// standalone, platform-neutral functions (SSR on the server, hydrate on the
|
||
// client). UI is built from the kjol webui kit + Tailwind utility classes.
|
||
//
|
||
// Directives (processed by kjol/cmd/wasmgen at build time):
|
||
//
|
||
// //gowasm:page <path> [static] [layout=<name>] a route (static => SSR'd)
|
||
// //gowasm:layout <name> a func(Deps, *VNode) *VNode wrapper
|
||
// //gowasm:server (see server_counter.go) a server component
|
||
package app
|
||
|
||
//go:generate go run kjol/cmd/wasmgen .
|
||
|
||
import (
|
||
"strconv"
|
||
|
||
. "kjol/vdom"
|
||
ui "kjol/webui"
|
||
)
|
||
|
||
// Deps are the client-only capabilities, injected so pages stay neutral.
|
||
type Deps struct {
|
||
Path func() string
|
||
Navigate func(string)
|
||
}
|
||
|
||
func itoa(n int) string { return strconv.Itoa(n) }
|
||
|
||
// Layout wraps a page's content with shared chrome (declared with //gowasm:layout,
|
||
// selected per route via `layout=`; the generated LayoutFor dispatches by name).
|
||
type Layout func(d Deps, content *VNode) *VNode
|
||
|
||
// Shell renders the current route's page inside its declared layout.
|
||
func Shell(d Deps, routes map[string]func() *VNode) *VNode {
|
||
path := d.Path()
|
||
var content *VNode
|
||
if page := routes[path]; page != nil {
|
||
content = page()
|
||
} else {
|
||
content = notFound(path)
|
||
}
|
||
return LayoutFor(d, path, content)
|
||
}
|
||
|
||
func notFound(path string) *VNode {
|
||
return Div(Attr("class", "py-10"),
|
||
H2(Attr("class", "text-xl font-semibold text-neutral-800 mb-2"), Text("Page not found")),
|
||
P(Attr("class", "text-neutral-500"), Text("No route matches "+path+".")),
|
||
)
|
||
}
|
||
|
||
// --- layouts (Tailwind chrome) -------------------------------------------
|
||
|
||
//gowasm:layout public
|
||
func PublicLayout(d Deps, content *VNode) *VNode {
|
||
return Div(
|
||
Nav(Attr("class", "site-nav sticky top-0 z-10 border-b border-neutral-200 bg-white"),
|
||
Div(Attr("class", "mx-auto flex max-w-5xl items-center gap-2 px-4 py-3"),
|
||
A(Attr("class", "text-lg font-semibold tracking-tight text-text-heading no-underline"), Attr("href", "/"), navigate(d, "/"), Text("gowasm")),
|
||
Ul(Attr("class", "ml-auto flex items-center gap-1"),
|
||
navItem(d, "/", "Home", false),
|
||
navItem(d, "/about", "About", false),
|
||
Li(Attr("class", "ml-2"),
|
||
A(Attr("class", "inline-flex items-center gap-1 rounded-default bg-primary px-3 py-1.5 text-sm font-medium text-white no-underline hover:bg-primary-hover"),
|
||
Attr("href", "/chart"), navigate(d, "/chart"), Text("Open app →"))),
|
||
))),
|
||
Main(Attr("class", "mx-auto max-w-5xl px-4 py-8"),
|
||
content,
|
||
Footer(Attr("class", "mt-12 border-t border-neutral-200 pt-4 text-sm text-neutral-400"),
|
||
Text("gowasm — pure-Go components compiled to WebAssembly.")),
|
||
),
|
||
)
|
||
}
|
||
|
||
// wideRoutes get a roomier container. A table with a dozen columns, a drag handle
|
||
// and three calculated columns has no business being squeezed into a reading-width
|
||
// column; prose pages still are.
|
||
var wideRoutes = map[string]bool{"/table": true}
|
||
|
||
//gowasm:layout app
|
||
func AppLayout(d Deps, content *VNode) *VNode {
|
||
width := "max-w-5xl"
|
||
if wideRoutes[d.Path()] {
|
||
width = "max-w-[100rem]"
|
||
}
|
||
|
||
return Div(Attr("class", "min-h-screen"),
|
||
Nav(Attr("class", "app-nav border-b border-neutral-800 bg-neutral-900"),
|
||
// The nav tracks the content's width, so the logo stays flush with the page
|
||
// rather than floating in from the left on the wide routes.
|
||
Div(Attr("class", "mx-auto flex "+width+" items-center gap-2 px-4 py-3"),
|
||
A(Attr("class", "text-lg font-semibold tracking-tight text-text-on-dark no-underline"), Attr("href", "/chart"), navigate(d, "/chart"), Text("gowasm · app")),
|
||
Ul(Attr("class", "ml-4 flex items-center gap-1"),
|
||
navItem(d, "/chart", "Chart", true),
|
||
navItem(d, "/server", "Server", true),
|
||
navItem(d, "/data", "Data", true),
|
||
navItem(d, "/table", "Table", true),
|
||
navItem(d, "/overlays", "Overlays", true),
|
||
navItem(d, "/kit", "UI Kit", true)),
|
||
Ul(Attr("class", "ml-auto flex items-center"),
|
||
navItem(d, "/", "Home", true)),
|
||
)),
|
||
Main(Attr("class", "mx-auto "+width+" px-4 py-8"), content),
|
||
|
||
// The host for webui.OpenModal — content opened imperatively, by code that
|
||
// owns no component in the tree, is portaled out of here. Render it ONCE,
|
||
// near the root. It is an empty portal when nothing is open.
|
||
ui.ModalHost(),
|
||
)
|
||
}
|
||
|
||
// navItem is a nav link with an active state; dark switches to on-dark colors.
|
||
func navItem(d Deps, path, label string, dark bool) *VNode {
|
||
active := d.Path() == path
|
||
var cls string
|
||
switch {
|
||
case dark && active:
|
||
cls = "active rounded-default px-3 py-1.5 text-sm font-medium bg-white/10 text-white"
|
||
case dark:
|
||
cls = "rounded-default px-3 py-1.5 text-sm font-medium text-neutral-300 hover:bg-white/5 hover:text-white"
|
||
case active:
|
||
cls = "active rounded-default px-3 py-1.5 text-sm font-medium bg-neutral-100 text-neutral-900"
|
||
default:
|
||
cls = "rounded-default px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
|
||
}
|
||
return Li(A(Attr("class", cls+" no-underline"), Attr("href", path), navigate(d, path), Text(label)))
|
||
}
|
||
|
||
// navigate intercepts a link click for client-side SPA navigation (Navigate is
|
||
// nil on the server, so the anchor falls back to a normal navigation).
|
||
func navigate(d Deps, path string) Mod {
|
||
return OnEvent(EVENT_CLICK, func(e Event) {
|
||
if d.Navigate != nil {
|
||
e.PreventDefault()
|
||
d.Navigate(path)
|
||
}
|
||
})
|
||
}
|
||
|
||
// Counter is a presentational client component; state is owned by the caller.
|
||
func Counter(label string, count *Signal[int]) *VNode {
|
||
return Div(Attr("class", "counter flex items-center gap-3 rounded-default border border-neutral-200 bg-white px-4 py-3 shadow-xs"),
|
||
Span(Attr("class", "font-medium text-neutral-700"), Text(label+": ")),
|
||
Strong(Attr("class", "badge inline-flex min-w-8 items-center justify-center rounded-full bg-primary px-2.5 py-0.5 text-sm font-semibold text-white"), Text(itoa(count.Get()))),
|
||
Div(Attr("class", "ml-auto flex gap-1"),
|
||
ui.Button(ui.ButtonProps{Color: ui.ButtonSecondary, Small: true, Text: "−", OnClick: func() { count.Update(func(v int) int { return v - 1 }) }}),
|
||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "+", OnClick: func() { count.Update(func(v int) int { return v + 1 }) }}),
|
||
),
|
||
)
|
||
}
|
||
|
||
//gowasm:page / static layout=public
|
||
func HomePage(d Deps) func() *VNode {
|
||
a := NewSignal(0)
|
||
b := NewSignal(0)
|
||
dark := NewSignal(false)
|
||
return func() *VNode {
|
||
return Div(Attr("class", "space-y-8"),
|
||
Div(
|
||
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Home — component composition")),
|
||
P(Attr("class", "mt-1 text-neutral-500"), Text("Two counters; the total is derived across them. Server-rendered, then hydrated.")),
|
||
),
|
||
Div(Attr("class", "grid gap-3 sm:grid-cols-2"),
|
||
Counter("Apples", a),
|
||
Counter("Bananas", b),
|
||
),
|
||
ui.Alert(ui.AlertBlue, "",
|
||
Span(Text("Combined total: ")),
|
||
Strong(Attr("class", "font-semibold"), Text(itoa(a.Get()+b.Get()))),
|
||
),
|
||
ui.Card("",
|
||
ui.CardHeader("", Text("webui kit")),
|
||
Div(Attr("class", "flex flex-wrap items-center gap-2"),
|
||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Primary"}),
|
||
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Text: "Green"}),
|
||
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Danger"}),
|
||
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Small: true, Icon: "check", Text: "Small"}),
|
||
ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active")),
|
||
ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber, Pill: true}, Text("pending")),
|
||
),
|
||
Div(Attr("class", "mt-4"),
|
||
ui.ToggleSwitch(dark.Get(), func(v bool) { dark.Set(v) }, "Dark mode", "Just a demo toggle", false, "")),
|
||
),
|
||
)
|
||
}
|
||
}
|
||
|
||
//gowasm:page /about static layout=public
|
||
func AboutPage(d Deps) func() *VNode {
|
||
return func() *VNode {
|
||
return Div(Attr("class", "space-y-6"),
|
||
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("About")),
|
||
ui.Card("",
|
||
P(Attr("class", "text-neutral-600 leading-relaxed"),
|
||
Text("Components are standalone Go functions; calling a server component looks "+
|
||
"identical to calling a client one — the //gowasm:server directive and the "+
|
||
"build-time codegen wire up the round-trip. Static routes are SSR'd; the rest "+
|
||
"render on the client. The UI kit (kjol/webui) is a Go port of the Solid.js "+
|
||
"component kit, styled with Tailwind.")),
|
||
),
|
||
ui.Alert(ui.AlertGreen, "Neutral + isomorphic",
|
||
Text("This page's markup runs on the server (SSR) and hydrates on the client from the same Go code.")),
|
||
)
|
||
}
|
||
}
|
||
|
||
//gowasm:page /server layout=app
|
||
func ServerPage(d Deps) func() *VNode {
|
||
// ServerCounter is a server component — calling it is just like calling any
|
||
// component. On the client this resolves to a generated stub that mounts it
|
||
// over /rsc; on the server it's the real function.
|
||
counter := ServerCounter()
|
||
return func() *VNode {
|
||
return Div(Attr("class", "space-y-6"),
|
||
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Server component")),
|
||
P(Attr("class", "text-neutral-500"),
|
||
Text("This counter runs on the server. Its state lives there; clicks round-trip "+
|
||
"and the returned render merges into the DOM.")),
|
||
counter(),
|
||
)
|
||
}
|
||
}
|