refactor: split nats-chatroom into modules with profile, layout, and auth
Extract chat, profile, types, and user data into separate files. Embed CSS via go:embed. Add a layout with nav, session-based profile persistence, and a middleware guard that redirects to /profile when no identity is set.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -48,6 +48,7 @@ internal/examples/plugins/plugins
|
|||||||
internal/examples/realtimechart/realtimechart
|
internal/examples/realtimechart/realtimechart
|
||||||
internal/examples/shakespeare/shakespeare
|
internal/examples/shakespeare/shakespeare
|
||||||
internal/examples/nats-chatroom/nats-chatroom
|
internal/examples/nats-chatroom/nats-chatroom
|
||||||
|
/nats-chatroom
|
||||||
|
|
||||||
# NATS data directory
|
# NATS data directory
|
||||||
data/
|
data/
|
||||||
|
|||||||
153
internal/examples/nats-chatroom/chat.css
Normal file
153
internal/examples/nats-chatroom/chat.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
148
internal/examples/nats-chatroom/chat.go
Normal file
148
internal/examples/nats-chatroom/chat.go
Normal file
@@ -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()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,40 +1,21 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "embed"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ryanhamamura/via"
|
"github.com/ryanhamamura/via"
|
||||||
"github.com/ryanhamamura/via/h"
|
"github.com/ryanhamamura/via/h"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
//go:embed chat.css
|
||||||
WithSignal = via.WithSignal
|
var chatCSS string
|
||||||
)
|
|
||||||
|
|
||||||
// ChatMessage represents a message in a chat room
|
var v *via.V
|
||||||
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"}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
v := via.New()
|
v = via.New()
|
||||||
v.Config(via.Options{
|
v.Config(via.Options{
|
||||||
DevMode: true,
|
DevMode: true,
|
||||||
DocumentTitle: "NATS Chat",
|
DocumentTitle: "NATS Chat",
|
||||||
@@ -54,62 +35,7 @@ func main() {
|
|||||||
|
|
||||||
v.AppendToHead(
|
v.AppendToHead(
|
||||||
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
|
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
|
||||||
h.StyleEl(h.Raw(`
|
h.StyleEl(h.Raw(chatCSS)),
|
||||||
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.Script(h.Raw(`
|
h.Script(h.Raw(`
|
||||||
function scrollChatToBottom() {
|
function scrollChatToBottom() {
|
||||||
const chatHistory = document.querySelector('.chat-history');
|
const chatHistory = document.querySelector('.chat-history');
|
||||||
@@ -118,156 +44,38 @@ func main() {
|
|||||||
`)),
|
`)),
|
||||||
)
|
)
|
||||||
|
|
||||||
v.Page("/", func(c *via.Context) {
|
v.Layout(func(content func() h.H) h.H {
|
||||||
currentUser := randUser()
|
return h.Div(
|
||||||
roomSignal := c.Signal("Go")
|
h.Nav(h.Class("app-nav"),
|
||||||
statement := c.Signal("")
|
h.A(h.Href("/"), h.Class("brand"), h.Text("NATS Chat")),
|
||||||
|
h.Div(h.Class("nav-links"),
|
||||||
var messages []ChatMessage
|
h.A(h.Href("/"), h.Text("Chat")),
|
||||||
var messagesMu sync.Mutex
|
h.A(h.Href("/profile"), h.Text("Profile")),
|
||||||
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)),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
h.Main(h.Class("container"),
|
||||||
}
|
h.DataViewTransition("page-content"),
|
||||||
messagesMu.Unlock()
|
content(),
|
||||||
|
|
||||||
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")),
|
|
||||||
),
|
|
||||||
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()),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
// 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)")
|
log.Println("Starting NATS chatroom on :7331 (embedded NATS server)")
|
||||||
v.Start()
|
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]
|
|
||||||
}
|
|
||||||
|
|||||||
110
internal/examples/nats-chatroom/profile.go
Normal file
110
internal/examples/nats-chatroom/profile.go
Normal file
@@ -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...),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
30
internal/examples/nats-chatroom/types.go
Normal file
30
internal/examples/nats-chatroom/types.go
Normal file
@@ -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{
|
||||||
|
"🐼", "🐯", "🦅", "🐬", "🦊", "🐺", "🐻", "🦦", "🦁", "🐸",
|
||||||
|
"🦄", "🐙", "🦀", "🐝", "🦋", "🐢", "🦉", "🐳", "🦈", "🐧",
|
||||||
|
}
|
||||||
22
internal/examples/nats-chatroom/userdata.go
Normal file
22
internal/examples/nats-chatroom/userdata.go
Normal file
@@ -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]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user