diff --git a/.gitignore b/.gitignore index 3d6612f..1c2a976 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ internal/examples/plugins/plugins internal/examples/realtimechart/realtimechart internal/examples/shakespeare/shakespeare internal/examples/nats-chatroom/nats-chatroom +/nats-chatroom # NATS data directory data/ diff --git a/internal/examples/nats-chatroom/chat.css b/internal/examples/nats-chatroom/chat.css new file mode 100644 index 0000000..3c5e5d2 --- /dev/null +++ b/internal/examples/nats-chatroom/chat.css @@ -0,0 +1,153 @@ +body { margin: 0; } + +/* Layout navbar */ +.app-nav { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 1rem; + background: var(--pico-card-background-color); + border-bottom: 1px solid var(--pico-muted-border-color); +} +.app-nav .brand { + font-weight: 700; + text-decoration: none; + margin-right: auto; +} +.nav-links { + display: flex; + gap: 1rem; +} +.nav-links a { + text-decoration: none; + font-size: 0.875rem; +} + +/* Chat page */ +.chat-page { + display: flex; + flex-direction: column; + height: calc(100vh - 53px); +} +nav[role="tab-control"] ul li a[aria-current="page"] { + background-color: var(--pico-primary-background); + color: var(--pico-primary-inverse); + border-bottom: 2px solid var(--pico-primary); +} +.chat-message { display: flex; gap: 0.75rem; margin-bottom: 0.5rem; } +.avatar { + width: 2rem; + height: 2rem; + border-radius: 50%; + background: var(--pico-muted-border-color); + display: grid; + place-items: center; + font-size: 1.5rem; + flex-shrink: 0; +} +.avatar-lg { + width: 3rem; + height: 3rem; + font-size: 2rem; +} +.bubble { flex: 1; } +.bubble p { margin: 0; } +.chat-history { + flex: 1; + overflow-y: auto; + padding: 1rem; + padding-bottom: calc(88px + env(safe-area-inset-bottom)); +} +.chat-input { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: var(--pico-background-color); + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom)); + border-top: 1px solid var(--pico-muted-border-color); +} +.chat-input fieldset { + flex: 1; + margin: 0; +} + +/* NATS badge with status dot */ +.nats-badge { + background: #27AAE1; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + margin-left: auto; + display: flex; + align-items: center; + gap: 0.375rem; +} +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #4ade80; + animation: pulse 2s ease-in-out infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Profile page */ +.profile-page { + max-width: 480px; + margin: 0 auto; + padding: 2rem 1rem; +} +.profile-preview { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--pico-card-background-color); + border-radius: 8px; +} +.preview-name { + font-size: 1.125rem; + font-weight: 600; +} +.profile-form label { + display: block; + margin-bottom: 0.5rem; +} +.emoji-grid { + display: grid; + grid-template-columns: repeat(10, 1fr); + gap: 0.25rem; + margin-bottom: 1.5rem; +} +.emoji-option { + padding: 0.375rem; + font-size: 1.25rem; + border: 2px solid transparent; + border-radius: 8px; + background: none; + cursor: pointer; + text-align: center; +} +.emoji-option:hover { + background: var(--pico-muted-border-color); +} +.emoji-selected { + border-color: var(--pico-primary); + background: var(--pico-primary-focus); +} +.profile-actions { + display: flex; + gap: 0.75rem; +} +.field-error { + color: var(--pico-del-color); +} diff --git a/internal/examples/nats-chatroom/chat.go b/internal/examples/nats-chatroom/chat.go new file mode 100644 index 0000000..9a2d118 --- /dev/null +++ b/internal/examples/nats-chatroom/chat.go @@ -0,0 +1,148 @@ +package main + +import ( + "sync" + "time" + + "github.com/ryanhamamura/via" + "github.com/ryanhamamura/via/h" +) + +var ( + WithSignal = via.WithSignal +) + +func ChatPage(c *via.Context) { + currentUser := UserInfo{ + Name: c.Session().GetString(SessionKeyUsername), + Emoji: c.Session().GetString(SessionKeyEmoji), + } + + roomSignal := c.Signal("Go") + statement := c.Signal("") + + var messages []ChatMessage + var messagesMu sync.Mutex + currentRoom := "Go" + + var currentSub via.Subscription + + subscribeToRoom := func(room string) { + if currentSub != nil { + currentSub.Unsubscribe() + } + + subject := "chat.room." + room + + if hist, err := via.ReplayHistory[ChatMessage](v, subject, 50); err == nil { + messages = hist + } + + sub, _ := via.Subscribe(c, subject, func(msg ChatMessage) { + messagesMu.Lock() + messages = append(messages, msg) + if len(messages) > 50 { + messages = messages[len(messages)-50:] + } + messagesMu.Unlock() + c.Sync() + }) + currentSub = sub + currentRoom = room + } + + subscribeToRoom("Go") + + // Heartbeat — keeps connected indicator alive + connected := true + c.OnInterval(30*time.Second, func() { + connected = true + c.Sync() + }) + + switchRoom := c.Action(func() { + newRoom := roomSignal.String() + if newRoom != currentRoom { + messagesMu.Lock() + messages = nil + messagesMu.Unlock() + subscribeToRoom(newRoom) + c.Sync() + } + }) + + say := c.Action(func() { + msg := statement.String() + if msg == "" { + msg = randomDevQuote() + } + statement.SetValue("") + + via.Publish(c, "chat.room."+currentRoom, ChatMessage{ + User: currentUser, + Message: msg, + Time: time.Now().UnixMilli(), + }) + }) + + c.View(func() h.H { + var tabs []h.H + for _, name := range roomNames { + isCurrent := name == currentRoom + tabs = append(tabs, h.Li( + h.A( + h.If(isCurrent, h.Attr("aria-current", "page")), + h.Text(name), + switchRoom.OnClick(WithSignal(roomSignal, name)), + ), + )) + } + + messagesMu.Lock() + chatHistoryChildren := []h.H{ + h.Class("chat-history"), + h.Script(h.Raw(`new MutationObserver(()=>scrollChatToBottom()).observe(document.querySelector('.chat-history'), {childList:true})`)), + } + for _, msg := range messages { + chatHistoryChildren = append(chatHistoryChildren, + h.Div(h.Class("chat-message"), + h.Div(h.Class("avatar"), h.Attr("title", msg.User.Name), h.Text(msg.User.Emoji)), + h.Div(h.Class("bubble"), + h.P(h.Text(msg.Message)), + ), + ), + ) + } + messagesMu.Unlock() + + _ = connected + + return h.Div(h.Class("chat-page"), + h.Nav( + h.Attr("role", "tab-control"), + h.Ul(tabs...), + h.Span(h.Class("nats-badge"), + h.Span(h.Class("status-dot")), + h.Text("NATS"), + ), + ), + h.Div(chatHistoryChildren...), + h.Div( + h.Class("chat-input"), + h.DataIgnoreMorph(), + currentUser.Avatar(), + h.FieldSet( + h.Attr("role", "group"), + h.Input( + h.Type("text"), + h.Placeholder(currentUser.Name+" says..."), + statement.Bind(), + h.Attr("autofocus"), + say.OnKeyDown("Enter"), + ), + h.Button(h.Text("Send"), say.OnClick()), + ), + ), + ) + }) +} diff --git a/internal/examples/nats-chatroom/main.go b/internal/examples/nats-chatroom/main.go index 6abf378..530827b 100644 --- a/internal/examples/nats-chatroom/main.go +++ b/internal/examples/nats-chatroom/main.go @@ -1,40 +1,21 @@ package main import ( + _ "embed" "log" - "math/rand" - "sync" "time" "github.com/ryanhamamura/via" "github.com/ryanhamamura/via/h" ) -var ( - WithSignal = via.WithSignal -) +//go:embed chat.css +var chatCSS string -// ChatMessage represents a message in a chat room -type ChatMessage struct { - User UserInfo `json:"user"` - Message string `json:"message"` - Time int64 `json:"time"` -} - -// UserInfo identifies a chat participant -type UserInfo struct { - Name string `json:"name"` - Emoji string `json:"emoji"` -} - -func (u *UserInfo) Avatar() h.H { - return h.Div(h.Class("avatar"), h.Attr("title", u.Name), h.Text(u.Emoji)) -} - -var roomNames = []string{"Go", "Rust", "Python", "JavaScript", "Clojure"} +var v *via.V func main() { - v := via.New() + v = via.New() v.Config(via.Options{ DevMode: true, DocumentTitle: "NATS Chat", @@ -54,62 +35,7 @@ func main() { v.AppendToHead( h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")), - h.StyleEl(h.Raw(` - body { margin: 0; } - main { - display: flex; - flex-direction: column; - height: 100vh; - } - nav[role="tab-control"] ul li a[aria-current="page"] { - background-color: var(--pico-primary-background); - color: var(--pico-primary-inverse); - border-bottom: 2px solid var(--pico-primary); - } - .chat-message { display: flex; gap: 0.75rem; margin-bottom: 0.5rem; } - .avatar { - width: 2rem; - height: 2rem; - border-radius: 50%; - background: var(--pico-muted-border-color); - display: grid; - place-items: center; - font-size: 1.5rem; - flex-shrink: 0; - } - .bubble { flex: 1; } - .bubble p { margin: 0; } - .chat-history { - flex: 1; - overflow-y: auto; - padding: 1rem; - padding-bottom: calc(88px + env(safe-area-inset-bottom)); - } - .chat-input { - position: fixed; - left: 0; - right: 0; - bottom: 0; - background: var(--pico-background-color); - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom)); - border-top: 1px solid var(--pico-muted-border-color); - } - .chat-input fieldset { - flex: 1; - margin: 0; - } - .nats-badge { - background: #27AAE1; - color: white; - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 0.75rem; - margin-left: auto; - } - `)), + h.StyleEl(h.Raw(chatCSS)), h.Script(h.Raw(` function scrollChatToBottom() { const chatHistory = document.querySelector('.chat-history'); @@ -118,156 +44,38 @@ func main() { `)), ) - v.Page("/", func(c *via.Context) { - currentUser := randUser() - roomSignal := c.Signal("Go") - statement := c.Signal("") - - var messages []ChatMessage - var messagesMu sync.Mutex - currentRoom := "Go" - - var currentSub via.Subscription - - subscribeToRoom := func(room string) { - if currentSub != nil { - currentSub.Unsubscribe() - } - - subject := "chat.room." + room - - // Replay history from JetStream - if hist, err := via.ReplayHistory[ChatMessage](v, subject, 50); err == nil { - messages = hist - } - - sub, _ := via.Subscribe(c, subject, func(msg ChatMessage) { - messagesMu.Lock() - messages = append(messages, msg) - if len(messages) > 50 { - messages = messages[len(messages)-50:] - } - messagesMu.Unlock() - c.Sync() - }) - currentSub = sub - currentRoom = room - } - - subscribeToRoom("Go") - - switchRoom := c.Action(func() { - newRoom := roomSignal.String() - if newRoom != currentRoom { - messagesMu.Lock() - messages = nil - messagesMu.Unlock() - subscribeToRoom(newRoom) - c.Sync() - } - }) - - say := c.Action(func() { - msg := statement.String() - if msg == "" { - msg = randomDevQuote() - } - statement.SetValue("") - - via.Publish(c, "chat.room."+currentRoom, ChatMessage{ - User: currentUser, - Message: msg, - Time: time.Now().UnixMilli(), - }) - }) - - c.View(func() h.H { - var tabs []h.H - for _, name := range roomNames { - isCurrent := name == currentRoom - tabs = append(tabs, h.Li( - h.A( - h.If(isCurrent, h.Attr("aria-current", "page")), - h.Text(name), - switchRoom.OnClick(WithSignal(roomSignal, name)), - ), - )) - } - - messagesMu.Lock() - chatHistoryChildren := []h.H{ - h.Class("chat-history"), - h.Script(h.Raw(`new MutationObserver(()=>scrollChatToBottom()).observe(document.querySelector('.chat-history'), {childList:true})`)), - } - for _, msg := range messages { - chatHistoryChildren = append(chatHistoryChildren, - h.Div(h.Class("chat-message"), - h.Div(h.Class("avatar"), h.Attr("title", msg.User.Name), h.Text(msg.User.Emoji)), - h.Div(h.Class("bubble"), - h.P(h.Text(msg.Message)), - ), - ), - ) - } - messagesMu.Unlock() - - return h.Main(h.Class("container"), - h.Nav( - h.Attr("role", "tab-control"), - h.Ul(tabs...), - h.Span(h.Class("nats-badge"), h.Text("NATS")), + v.Layout(func(content func() h.H) h.H { + return h.Div( + h.Nav(h.Class("app-nav"), + h.A(h.Href("/"), h.Class("brand"), h.Text("NATS Chat")), + h.Div(h.Class("nav-links"), + h.A(h.Href("/"), h.Text("Chat")), + h.A(h.Href("/profile"), h.Text("Profile")), ), - h.Div(chatHistoryChildren...), - h.Div( - h.Class("chat-input"), - currentUser.Avatar(), - h.FieldSet( - h.Attr("role", "group"), - h.Input( - h.Type("text"), - h.Placeholder(currentUser.Name+" says..."), - statement.Bind(), - h.Attr("autofocus"), - say.OnKeyDown("Enter"), - ), - h.Button(h.Text("Send"), say.OnClick()), - ), - ), - ) - }) + ), + h.Main(h.Class("container"), + h.DataViewTransition("page-content"), + content(), + ), + ) }) + // Profile page — public, no auth required + v.Page("/profile", ProfilePage) + + // Auth middleware — redirects to profile if no identity set + requireProfile := func(c *via.Context, next func()) { + if c.Session().GetString(SessionKeyUsername) == "" { + c.RedirectView("/profile") + return + } + next() + } + + // Chat page — protected by profile middleware + protected := v.Group("", requireProfile) + protected.Page("/", ChatPage) + log.Println("Starting NATS chatroom on :7331 (embedded NATS server)") v.Start() } - -func randUser() UserInfo { - adjectives := []string{"Happy", "Clever", "Brave", "Swift", "Gentle", "Wise", "Bold", "Calm", "Eager", "Fierce"} - animals := []string{"Panda", "Tiger", "Eagle", "Dolphin", "Fox", "Wolf", "Bear", "Hawk", "Otter", "Lion"} - emojis := []string{"🐼", "🐯", "🦅", "🐬", "🦊", "🐺", "🐻", "🦅", "🦦", "🦁"} - - idx := rand.Intn(len(animals)) - return UserInfo{ - Name: adjectives[rand.Intn(len(adjectives))] + " " + animals[idx], - Emoji: emojis[idx], - } -} - -var quoteIdx = rand.Intn(len(devQuotes)) -var devQuotes = []string{ - "Just use NATS.", - "Pub/sub all the things!", - "Messages are the new API.", - "JetStream for durability.", - "No more polling.", - "Event-driven architecture FTW.", - "Decouple everything.", - "NATS is fast.", - "Subjects are like topics.", - "Request-reply is cool.", -} - -func randomDevQuote() string { - quoteIdx = (quoteIdx + 1) % len(devQuotes) - return devQuotes[quoteIdx] -} diff --git a/internal/examples/nats-chatroom/profile.go b/internal/examples/nats-chatroom/profile.go new file mode 100644 index 0000000..8ad762f --- /dev/null +++ b/internal/examples/nats-chatroom/profile.go @@ -0,0 +1,110 @@ +package main + +import ( + "github.com/ryanhamamura/via" + "github.com/ryanhamamura/via/h" +) + +func ProfilePage(c *via.Context) { + existingName := c.Session().GetString(SessionKeyUsername) + existingEmoji := c.Session().GetString(SessionKeyEmoji) + if existingEmoji == "" { + existingEmoji = emojiChoices[0] + } + + nameField := c.Field(existingName, + via.Required("Display name is required"), + via.MinLen(2, "Must be at least 2 characters"), + via.MaxLen(20, "Must be at most 20 characters"), + ) + selectedEmoji := c.Signal(existingEmoji) + + saveToSession := func() bool { + if !c.ValidateAll() { + c.Sync() + return false + } + c.Session().Set(SessionKeyUsername, nameField.String()) + c.Session().Set(SessionKeyEmoji, selectedEmoji.String()) + return true + } + + save := c.Action(func() { + saveToSession() + }) + + saveAndChat := c.Action(func() { + if saveToSession() { + c.Navigate("/", false) + } + }) + + c.View(func() h.H { + // Emoji grid + emojiGrid := []h.H{h.Class("emoji-grid")} + for _, emoji := range emojiChoices { + cls := "emoji-option" + if emoji == selectedEmoji.String() { + cls += " emoji-selected" + } + emojiGrid = append(emojiGrid, + h.Button( + h.Class(cls), + h.Type("button"), + h.Text(emoji), + save.OnClick(WithSignal(selectedEmoji, emoji)), + ), + ) + } + + // Action buttons — "Start Chatting" only if editing is meaningful + actionButtons := []h.H{h.Class("profile-actions")} + if existingName != "" { + actionButtons = append(actionButtons, + h.Button(h.Text("Save"), save.OnClick(), h.Class("secondary")), + ) + } + actionButtons = append(actionButtons, + h.Button(h.Text("Start Chatting"), saveAndChat.OnClick()), + ) + + previewName := nameField.String() + if previewName == "" { + previewName = "Your Name" + } + + return h.Div(h.Class("profile-page"), + h.H2(h.Text("Your Profile"), h.DataViewTransition("page-title")), + + // Live preview + h.Div(h.Class("profile-preview"), + h.Div(h.Class("avatar avatar-lg"), h.Text(selectedEmoji.String())), + h.Span(h.Class("preview-name"), h.Text(previewName)), + ), + + h.Div(h.Class("profile-form"), + // Name field + h.Label(h.Text("Display Name"), + h.Input( + h.Type("text"), + h.Placeholder("Enter a display name"), + nameField.Bind(), + h.Attr("autofocus"), + saveAndChat.OnKeyDown("Enter"), + h.If(nameField.HasError(), h.Attr("aria-invalid", "true")), + ), + h.If(nameField.HasError(), + h.Small(h.Class("field-error"), h.Text(nameField.FirstError())), + ), + ), + + // Emoji picker + h.Label(h.Text("Choose an Avatar")), + h.Div(emojiGrid...), + + // Actions + h.Div(actionButtons...), + ), + ) + }) +} diff --git a/internal/examples/nats-chatroom/types.go b/internal/examples/nats-chatroom/types.go new file mode 100644 index 0000000..1ca15c8 --- /dev/null +++ b/internal/examples/nats-chatroom/types.go @@ -0,0 +1,30 @@ +package main + +import "github.com/ryanhamamura/via/h" + +const ( + SessionKeyUsername = "username" + SessionKeyEmoji = "emoji" +) + +type ChatMessage struct { + User UserInfo `json:"user"` + Message string `json:"message"` + Time int64 `json:"time"` +} + +type UserInfo struct { + Name string `json:"name"` + Emoji string `json:"emoji"` +} + +func (u *UserInfo) Avatar() h.H { + return h.Div(h.Class("avatar"), h.Attr("title", u.Name), h.Text(u.Emoji)) +} + +var roomNames = []string{"Go", "Rust", "Python", "JavaScript", "Clojure"} + +var emojiChoices = []string{ + "🐼", "🐯", "🦅", "🐬", "🦊", "🐺", "🐻", "🦦", "🦁", "🐸", + "🦄", "🐙", "🦀", "🐝", "🦋", "🐢", "🦉", "🐳", "🦈", "🐧", +} diff --git a/internal/examples/nats-chatroom/userdata.go b/internal/examples/nats-chatroom/userdata.go new file mode 100644 index 0000000..56ae0e5 --- /dev/null +++ b/internal/examples/nats-chatroom/userdata.go @@ -0,0 +1,22 @@ +package main + +import "math/rand" + +var quoteIdx = rand.Intn(len(devQuotes)) +var devQuotes = []string{ + "Just use NATS.", + "Pub/sub all the things!", + "Messages are the new API.", + "JetStream for durability.", + "No more polling.", + "Event-driven architecture FTW.", + "Decouple everything.", + "NATS is fast.", + "Subjects are like topics.", + "Request-reply is cool.", +} + +func randomDevQuote() string { + quoteIdx = (quoteIdx + 1) % len(devQuotes) + return devQuotes[quoteIdx] +}