Some checks failed
CI / Build and Test (push) Has been cancelled
Streams listed in Options.Streams are created by Start() when the embedded NATS server initializes, replacing manual EnsureStream calls during setup. Migrates nats-chatroom and pubsub-crud examples.
271 lines
6.3 KiB
Go
271 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"fmt"
|
|
"html"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ryanhamamura/via"
|
|
"github.com/ryanhamamura/via/h"
|
|
)
|
|
|
|
var WithSignal = via.WithSignal
|
|
|
|
type Bookmark struct {
|
|
ID string
|
|
Title string
|
|
URL string
|
|
}
|
|
|
|
type CRUDEvent struct {
|
|
Action string `json:"action"`
|
|
Title string `json:"title"`
|
|
UserID string `json:"user_id"`
|
|
}
|
|
|
|
var (
|
|
bookmarks []Bookmark
|
|
bookmarksMu sync.RWMutex
|
|
)
|
|
|
|
func randomHex(n int) string {
|
|
b := make([]byte, n)
|
|
rand.Read(b)
|
|
return fmt.Sprintf("%x", b)
|
|
}
|
|
|
|
func findBookmark(id string) (Bookmark, int) {
|
|
for i, bm := range bookmarks {
|
|
if bm.ID == id {
|
|
return bm, i
|
|
}
|
|
}
|
|
return Bookmark{}, -1
|
|
}
|
|
|
|
func main() {
|
|
v := via.New()
|
|
v.Config(via.Options{
|
|
DevMode: true,
|
|
DocumentTitle: "Bookmarks",
|
|
LogLevel: via.LogLevelInfo,
|
|
ServerAddress: ":7331",
|
|
Streams: []via.StreamConfig{{
|
|
Name: "BOOKMARKS",
|
|
Subjects: []string{"bookmarks.>"},
|
|
MaxMsgs: 1000,
|
|
MaxAge: 24 * time.Hour,
|
|
}},
|
|
})
|
|
|
|
v.AppendToHead(
|
|
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css")),
|
|
h.Script(h.Src("https://cdn.tailwindcss.com")),
|
|
)
|
|
|
|
v.Page("/", func(c *via.Context) {
|
|
userID := randomHex(8)
|
|
|
|
titleSignal := c.Signal("")
|
|
urlSignal := c.Signal("")
|
|
targetIDSignal := c.Signal("")
|
|
saveLabel := c.Computed(func() string {
|
|
if targetIDSignal.String() != "" {
|
|
return "Update Bookmark"
|
|
}
|
|
return "Add Bookmark"
|
|
})
|
|
|
|
via.Subscribe(c, "bookmarks.events", func(evt CRUDEvent) {
|
|
if evt.UserID == userID {
|
|
return
|
|
}
|
|
safeTitle := html.EscapeString(evt.Title)
|
|
var alertClass string
|
|
switch evt.Action {
|
|
case "created":
|
|
alertClass = "alert-success"
|
|
case "updated":
|
|
alertClass = "alert-info"
|
|
case "deleted":
|
|
alertClass = "alert-error"
|
|
}
|
|
c.ExecScript(fmt.Sprintf(`(function(){
|
|
var tc = document.getElementById('toast-container');
|
|
if (!tc) return;
|
|
var d = document.createElement('div');
|
|
d.className = 'alert %s';
|
|
d.innerHTML = '<span>Bookmark "%s" %s</span>';
|
|
tc.appendChild(d);
|
|
setTimeout(function(){ d.remove(); }, 3000);
|
|
})()`, alertClass, safeTitle, evt.Action))
|
|
c.Sync()
|
|
})
|
|
|
|
save := c.Action(func() {
|
|
title := titleSignal.String()
|
|
url := urlSignal.String()
|
|
if title == "" || url == "" {
|
|
return
|
|
}
|
|
|
|
targetID := targetIDSignal.String()
|
|
action := "created"
|
|
|
|
bookmarksMu.Lock()
|
|
if targetID != "" {
|
|
if _, idx := findBookmark(targetID); idx >= 0 {
|
|
bookmarks[idx].Title = title
|
|
bookmarks[idx].URL = url
|
|
action = "updated"
|
|
}
|
|
} else {
|
|
bookmarks = append(bookmarks, Bookmark{
|
|
ID: randomHex(8),
|
|
Title: title,
|
|
URL: url,
|
|
})
|
|
}
|
|
bookmarksMu.Unlock()
|
|
|
|
titleSignal.SetValue("")
|
|
urlSignal.SetValue("")
|
|
targetIDSignal.SetValue("")
|
|
|
|
via.Publish(c, "bookmarks.events", CRUDEvent{
|
|
Action: action,
|
|
Title: title,
|
|
UserID: userID,
|
|
})
|
|
c.Sync()
|
|
})
|
|
|
|
edit := c.Action(func() {
|
|
id := targetIDSignal.String()
|
|
bookmarksMu.RLock()
|
|
bm, idx := findBookmark(id)
|
|
bookmarksMu.RUnlock()
|
|
if idx < 0 {
|
|
return
|
|
}
|
|
titleSignal.SetValue(bm.Title)
|
|
urlSignal.SetValue(bm.URL)
|
|
})
|
|
|
|
del := c.Action(func() {
|
|
id := targetIDSignal.String()
|
|
bookmarksMu.Lock()
|
|
bm, idx := findBookmark(id)
|
|
if idx >= 0 {
|
|
bookmarks = append(bookmarks[:idx], bookmarks[idx+1:]...)
|
|
}
|
|
bookmarksMu.Unlock()
|
|
if idx < 0 {
|
|
return
|
|
}
|
|
|
|
targetIDSignal.SetValue("")
|
|
|
|
via.Publish(c, "bookmarks.events", CRUDEvent{
|
|
Action: "deleted",
|
|
Title: bm.Title,
|
|
UserID: userID,
|
|
})
|
|
c.Sync()
|
|
})
|
|
|
|
cancelEdit := c.Action(func() {
|
|
titleSignal.SetValue("")
|
|
urlSignal.SetValue("")
|
|
targetIDSignal.SetValue("")
|
|
})
|
|
|
|
c.View(func() h.H {
|
|
isEditing := targetIDSignal.String() != ""
|
|
|
|
// Build table rows
|
|
bookmarksMu.RLock()
|
|
var rows []h.H
|
|
for _, bm := range bookmarks {
|
|
rows = append(rows, h.Tr(
|
|
h.Td(h.Text(bm.Title)),
|
|
h.Td(h.A(h.Href(bm.URL), h.Attr("target", "_blank"), h.Class("link link-primary"), h.Text(bm.URL))),
|
|
h.Td(
|
|
h.Div(h.Class("flex gap-1"),
|
|
h.Button(h.Class("btn btn-xs btn-ghost"), h.Text("Edit"),
|
|
edit.OnClick(WithSignal(targetIDSignal, bm.ID)),
|
|
),
|
|
h.Button(h.Class("btn btn-xs btn-ghost text-error"), h.Text("Delete"),
|
|
del.OnClick(WithSignal(targetIDSignal, bm.ID)),
|
|
),
|
|
),
|
|
),
|
|
))
|
|
}
|
|
bookmarksMu.RUnlock()
|
|
|
|
return h.Div(h.Class("min-h-screen bg-base-200"),
|
|
// Navbar
|
|
h.Div(h.Class("navbar bg-base-100 shadow-sm"),
|
|
h.Div(h.Class("flex-1"),
|
|
h.A(h.Class("btn btn-ghost text-xl"), h.Text("Bookmarks")),
|
|
),
|
|
h.Div(h.Class("flex-none"),
|
|
h.Div(h.Class("badge badge-outline"), h.Text(userID[:8])),
|
|
),
|
|
),
|
|
|
|
h.Div(h.Class("container mx-auto p-4 max-w-3xl flex flex-col gap-4"),
|
|
// Form card
|
|
h.Div(h.Class("card bg-base-100 shadow"),
|
|
h.Div(h.Class("card-body"),
|
|
h.H2(h.Class("card-title"), saveLabel.Text()),
|
|
h.Div(h.Class("flex flex-col gap-2"),
|
|
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("Title"), titleSignal.Bind()),
|
|
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("https://example.com"), urlSignal.Bind()),
|
|
h.Div(h.Class("card-actions justify-end"),
|
|
h.If(isEditing,
|
|
h.Button(h.Class("btn btn-ghost"), h.Text("Cancel"), cancelEdit.OnClick()),
|
|
),
|
|
h.Button(h.Class("btn btn-primary"), saveLabel.Text(), save.OnClick()),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Table card
|
|
h.Div(h.Class("card bg-base-100 shadow"),
|
|
h.Div(h.Class("card-body"),
|
|
h.H2(h.Class("card-title"), h.Text("All Bookmarks")),
|
|
h.If(len(rows) == 0,
|
|
h.P(h.Class("text-base-content/60"), h.Text("No bookmarks yet. Add one above!")),
|
|
),
|
|
h.If(len(rows) > 0,
|
|
h.Div(h.Class("overflow-x-auto"),
|
|
h.Table(h.Class("table"),
|
|
h.THead(h.Tr(
|
|
h.Th(h.Text("Title")),
|
|
h.Th(h.Text("URL")),
|
|
h.Th(h.Text("Actions")),
|
|
)),
|
|
h.TBody(rows...),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Toast container — ignored by morph so Sync() doesn't wipe active toasts
|
|
h.Div(h.ID("toast-container"), h.Class("toast toast-end toast-top"), h.DataIgnoreMorph()),
|
|
)
|
|
})
|
|
})
|
|
|
|
log.Println("Starting pubsub-crud example on :7331")
|
|
v.Start()
|
|
}
|