commit
caad5633d0
58 changed files with 2135 additions and 74 deletions
49
.github/workflows/release-chat-example.yml
vendored
Normal file
49
.github/workflows/release-chat-example.yml
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
name: Build and Deploy htmgo.dev chat example
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
workflow_dispatch: # Trigger on manual workflow_dispatch
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master # Trigger on pushes to master
|
||||||
|
paths:
|
||||||
|
- 'examples/chat/**' # Trigger only if files in this directory change
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Get short commit hash
|
||||||
|
id: vars
|
||||||
|
run: echo "::set-output name=short_sha::$(echo $GITHUB_SHA | cut -c1-7)"
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
cd ./examples/chat && docker build -t ghcr.io/${{ github.repository_owner }}/htmgo-chat-example:${{ steps.vars.outputs.short_sha }} .
|
||||||
|
|
||||||
|
- name: Tag as latest Docker image
|
||||||
|
run: |
|
||||||
|
docker tag ghcr.io/${{ github.repository_owner }}/htmgo-chat-example:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/htmgo-chat-example:latest
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
run: |
|
||||||
|
docker push ghcr.io/${{ github.repository_owner }}/htmgo-chat-example:latest
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/astgen"
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/astgen"
|
||||||
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/copyassets"
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/css"
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/css"
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/run"
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/run"
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/tasks/util"
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/util"
|
||||||
|
|
@ -101,6 +102,12 @@ func OnFileChange(version string, events []*fsnotify.Event) {
|
||||||
hasTask = true
|
hasTask = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// framework assets changed
|
||||||
|
if c.HasAnySuffix("assets/dist/htmgo.js") {
|
||||||
|
copyassets.CopyAssets()
|
||||||
|
//tasks.Run = true
|
||||||
|
}
|
||||||
|
|
||||||
if hasTask {
|
if hasTask {
|
||||||
slog.Info("file changed", slog.String("version", version), slog.String("file", c.Name()))
|
slog.Info("file changed", slog.String("version", version), slog.String("file", c.Name()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import (
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/maddalax/htmgo/cli/htmgo/internal"
|
"github.com/maddalax/htmgo/cli/htmgo/internal"
|
||||||
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/module"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -36,6 +38,7 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
slog.Debug("event:", slog.String("name", event.Name), slog.String("op", event.Op.String()))
|
||||||
if event.Has(fsnotify.Write) || event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
|
if event.Has(fsnotify.Write) || event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
|
||||||
events = append(events, &event)
|
events = append(events, &event)
|
||||||
debouncer.Do(func() {
|
debouncer.Do(func() {
|
||||||
|
|
@ -61,6 +64,15 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
rootDir := "."
|
rootDir := "."
|
||||||
|
|
||||||
|
frameworkPath := module.GetDependencyPath("github.com/maddalax/htmgo/framework")
|
||||||
|
|
||||||
|
if !strings.HasPrefix(frameworkPath, "github.com/") {
|
||||||
|
assetPath := filepath.Join(frameworkPath, "assets", "dist")
|
||||||
|
slog.Debug("Watching directory:", slog.String("path", assetPath))
|
||||||
|
watcher.Add(assetPath)
|
||||||
|
}
|
||||||
|
|
||||||
// Walk through the root directory and add all subdirectories to the watcher
|
// Walk through the root directory and add all subdirectories to the watcher
|
||||||
err = filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
|
err = filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
11
examples/chat/.dockerignore
Normal file
11
examples/chat/.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Project exclude paths
|
||||||
|
/tmp/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
js/dist
|
||||||
|
js/node_modules
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
.idea
|
||||||
|
!framework/assets/dist
|
||||||
|
__htmgo
|
||||||
6
examples/chat/.gitignore
vendored
Normal file
6
examples/chat/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/assets/dist
|
||||||
|
tmp
|
||||||
|
node_modules
|
||||||
|
.idea
|
||||||
|
__htmgo
|
||||||
|
dist
|
||||||
36
examples/chat/Dockerfile
Normal file
36
examples/chat/Dockerfile
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Stage 1: Build the Go binary
|
||||||
|
FROM golang:1.23 AS builder
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go.mod and go.sum files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Download and cache the Go modules
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy the source code into the container
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the Go binary for Linux
|
||||||
|
RUN CGO_ENABLED=0 GOPRIVATE=github.com/maddalax LOG_LEVEL=debug go run github.com/maddalax/htmgo/cli/htmgo@8b816e956692683337d9fff6416ccc31f5047b59 build
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=1 GOOS=linux go build -tags prod -o ./dist -a -ldflags '-linkmode external -extldflags "-static"' .
|
||||||
|
|
||||||
|
|
||||||
|
# Stage 2: Create the smallest possible image
|
||||||
|
FROM gcr.io/distroless/base-debian11
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the Go binary from the builder stage
|
||||||
|
COPY --from=builder /app/dist .
|
||||||
|
|
||||||
|
# Expose the necessary port (replace with your server port)
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|
||||||
|
# Command to run the binary
|
||||||
|
CMD ["./chat"]
|
||||||
20
examples/chat/Taskfile.yml
Normal file
20
examples/chat/Taskfile.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
run:
|
||||||
|
cmds:
|
||||||
|
- go run github.com/maddalax/htmgo/cli/htmgo@latest run
|
||||||
|
silent: true
|
||||||
|
|
||||||
|
build:
|
||||||
|
cmds:
|
||||||
|
- go run github.com/maddalax/htmgo/cli/htmgo@latest build
|
||||||
|
|
||||||
|
docker:
|
||||||
|
cmds:
|
||||||
|
- docker build .
|
||||||
|
|
||||||
|
watch:
|
||||||
|
cmds:
|
||||||
|
- go run github.com/maddalax/htmgo/cli/htmgo@latest watch
|
||||||
|
silent: true
|
||||||
13
examples/chat/assets.go
Normal file
13
examples/chat/assets.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//go:build !prod
|
||||||
|
// +build !prod
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"chat/internal/embedded"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetStaticAssets() fs.FS {
|
||||||
|
return embedded.NewOsFs()
|
||||||
|
}
|
||||||
3
examples/chat/assets/css/input.css
Normal file
3
examples/chat/assets/css/input.css
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
16
examples/chat/assets_prod.go
Normal file
16
examples/chat/assets_prod.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//go:build prod
|
||||||
|
// +build prod
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/dist/*
|
||||||
|
var staticAssets embed.FS
|
||||||
|
|
||||||
|
func GetStaticAssets() fs.FS {
|
||||||
|
return staticAssets
|
||||||
|
}
|
||||||
155
examples/chat/chat/broadcast.go
Normal file
155
examples/chat/chat/broadcast.go
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"chat/internal/db"
|
||||||
|
"chat/ws"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"github.com/maddalax/htmgo/framework/service"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
socketManager *ws.SocketManager
|
||||||
|
queries *db.Queries
|
||||||
|
service *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(locator *service.Locator) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
socketManager: service.Get[ws.SocketManager](locator),
|
||||||
|
queries: service.Get[db.Queries](locator),
|
||||||
|
service: NewService(locator),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) StartListener() {
|
||||||
|
c := make(chan ws.SocketEvent, 1)
|
||||||
|
m.socketManager.Listen(c)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event := <-c:
|
||||||
|
switch event.Type {
|
||||||
|
case ws.ConnectedEvent:
|
||||||
|
m.OnConnected(event)
|
||||||
|
case ws.DisconnectedEvent:
|
||||||
|
m.OnDisconnected(event)
|
||||||
|
case ws.MessageEvent:
|
||||||
|
m.onMessage(event)
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unknown event type: %s\n", event.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) dispatchConnectedUsers(roomId string, predicate func(conn ws.SocketConnection) bool) {
|
||||||
|
|
||||||
|
connectedUsers := make([]db.User, 0)
|
||||||
|
|
||||||
|
// backfill all existing clients to the connected client
|
||||||
|
m.socketManager.ForEachSocket(roomId, func(conn ws.SocketConnection) {
|
||||||
|
if !predicate(conn) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := m.queries.GetUserBySessionId(context.Background(), conn.Id)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connectedUsers = append(connectedUsers, user)
|
||||||
|
})
|
||||||
|
|
||||||
|
m.socketManager.ForEachSocket(roomId, func(conn ws.SocketConnection) {
|
||||||
|
m.socketManager.SendText(conn.Id, h.Render(ConnectedUsers(connectedUsers, conn.Id)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) OnConnected(e ws.SocketEvent) {
|
||||||
|
room, _ := m.service.GetRoom(e.RoomId)
|
||||||
|
|
||||||
|
if room == nil {
|
||||||
|
m.socketManager.CloseWithMessage(e.Id, "invalid room")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
m.socketManager.CloseWithMessage(e.Id, "invalid user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("User %s connected to %s\n", user.Name, e.RoomId)
|
||||||
|
|
||||||
|
m.dispatchConnectedUsers(e.RoomId, func(conn ws.SocketConnection) bool {
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
m.backFill(e.Id, e.RoomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) OnDisconnected(e ws.SocketEvent) {
|
||||||
|
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
room, err := m.service.GetRoom(e.RoomId)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("User %s disconnected from %s\n", user.Name, room.ID)
|
||||||
|
m.dispatchConnectedUsers(e.RoomId, func(conn ws.SocketConnection) bool {
|
||||||
|
return conn.Id != e.Id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) backFill(socketId string, roomId string) {
|
||||||
|
messages, _ := m.queries.GetLastMessages(context.Background(), db.GetLastMessagesParams{
|
||||||
|
ChatRoomID: roomId,
|
||||||
|
Limit: 200,
|
||||||
|
})
|
||||||
|
for _, message := range messages {
|
||||||
|
parsed, _ := time.Parse("2006-01-02 15:04:05", message.CreatedAt)
|
||||||
|
m.socketManager.SendText(socketId,
|
||||||
|
h.Render(MessageRow(&Message{
|
||||||
|
UserId: message.UserID,
|
||||||
|
UserName: message.UserName,
|
||||||
|
Message: message.Message,
|
||||||
|
CreatedAt: parsed,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) onMessage(e ws.SocketEvent) {
|
||||||
|
message := e.Payload["message"].(string)
|
||||||
|
|
||||||
|
if message == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error getting user: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saved := m.service.InsertMessage(
|
||||||
|
&user,
|
||||||
|
e.RoomId,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
|
||||||
|
if saved != nil {
|
||||||
|
m.socketManager.BroadcastText(
|
||||||
|
e.RoomId,
|
||||||
|
h.Render(MessageRow(saved)),
|
||||||
|
func(conn ws.SocketConnection) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
examples/chat/chat/component.go
Normal file
51
examples/chat/chat/component.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"chat/internal/db"
|
||||||
|
"fmt"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MessageRow(message *Message) *h.Element {
|
||||||
|
return h.Div(
|
||||||
|
h.Attribute("hx-swap-oob", "beforeend"),
|
||||||
|
h.Class("flex flex-col gap-4 w-full break-words whitespace-normal"), // Ensure container breaks long words
|
||||||
|
h.Id("messages"),
|
||||||
|
h.Div(
|
||||||
|
h.Class("flex flex-col gap-1"),
|
||||||
|
h.Div(
|
||||||
|
h.Class("flex gap-2 items-center"),
|
||||||
|
h.Pf(message.UserName, h.Class("font-bold")),
|
||||||
|
h.Pf(message.CreatedAt.In(time.Local).Format("01/02 03:04 PM")),
|
||||||
|
),
|
||||||
|
h.Article(
|
||||||
|
h.Class("break-words whitespace-normal"), // Ensure message text wraps correctly
|
||||||
|
h.P(h.Text(message.Message)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConnectedUsers(users []db.User, myId string) *h.Element {
|
||||||
|
return h.Ul(
|
||||||
|
h.Attribute("hx-swap-oob", "outerHTML"),
|
||||||
|
h.Id("connected-users"),
|
||||||
|
h.Class("flex flex-col"),
|
||||||
|
h.List(users, func(user db.User, index int) *h.Element {
|
||||||
|
return connectedUser(user.Name, user.SessionID == myId)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectedUser(username string, isMe bool) *h.Element {
|
||||||
|
id := fmt.Sprintf("connected-user-%s", strings.ReplaceAll(username, "#", "-"))
|
||||||
|
return h.Li(
|
||||||
|
h.Id(id),
|
||||||
|
h.ClassX("truncate text-slate-700", h.ClassMap{
|
||||||
|
"font-bold": isMe,
|
||||||
|
}),
|
||||||
|
h.Text(username),
|
||||||
|
)
|
||||||
|
}
|
||||||
84
examples/chat/chat/service.go
Normal file
84
examples/chat/chat/service.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"chat/internal/db"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/maddalax/htmgo/framework/service"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
UserId int64 `json:"userId"`
|
||||||
|
UserName string `json:"userName"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
queries *db.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(locator *service.Locator) *Service {
|
||||||
|
return &Service{
|
||||||
|
queries: service.Get[db.Queries](locator),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) InsertMessage(user *db.User, roomId string, message string) *Message {
|
||||||
|
err := s.queries.InsertMessage(context.Background(), db.InsertMessageParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Name,
|
||||||
|
ChatRoomID: roomId,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to insert message: %v\n", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Message{
|
||||||
|
UserId: user.ID,
|
||||||
|
UserName: user.Name,
|
||||||
|
Message: message,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetUserBySession(sessionId string) (*db.User, error) {
|
||||||
|
user, err := s.queries.GetUserBySessionId(context.Background(), sessionId)
|
||||||
|
return &user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateUser(name string) (*db.CreateUserRow, error) {
|
||||||
|
nameWithHash := fmt.Sprintf("%s#%s", name, uuid.NewString()[0:4])
|
||||||
|
sessionId := fmt.Sprintf("session-%s-%s", uuid.NewString(), uuid.NewString())
|
||||||
|
user, err := s.queries.CreateUser(context.Background(), db.CreateUserParams{
|
||||||
|
Name: nameWithHash,
|
||||||
|
SessionID: sessionId,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateRoom(name string) (*db.CreateChatRoomRow, error) {
|
||||||
|
room, err := s.queries.CreateChatRoom(context.Background(), db.CreateChatRoomParams{
|
||||||
|
ID: fmt.Sprintf("room-%s-%s", uuid.NewString()[0:8], name),
|
||||||
|
Name: name,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &room, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetRoom(id string) (*db.ChatRoom, error) {
|
||||||
|
room, err := s.queries.GetChatRoom(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &room, nil
|
||||||
|
}
|
||||||
41
examples/chat/components/button.go
Normal file
41
examples/chat/components/button.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package components
|
||||||
|
|
||||||
|
import "github.com/maddalax/htmgo/framework/h"
|
||||||
|
|
||||||
|
type ButtonProps struct {
|
||||||
|
Id string
|
||||||
|
Text string
|
||||||
|
Target string
|
||||||
|
Type string
|
||||||
|
Trigger string
|
||||||
|
Get string
|
||||||
|
Class string
|
||||||
|
Children []h.Ren
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrimaryButton(props ButtonProps) h.Ren {
|
||||||
|
props.Class = h.MergeClasses(props.Class, "border-slate-800 bg-slate-900 hover:bg-slate-800 text-white")
|
||||||
|
return Button(props)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SecondaryButton(props ButtonProps) h.Ren {
|
||||||
|
props.Class = h.MergeClasses(props.Class, "border-gray-700 bg-gray-700 text-white")
|
||||||
|
return Button(props)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Button(props ButtonProps) h.Ren {
|
||||||
|
|
||||||
|
text := h.Text(props.Text)
|
||||||
|
|
||||||
|
button := h.Button(
|
||||||
|
h.If(props.Id != "", h.Id(props.Id)),
|
||||||
|
h.If(props.Children != nil, h.Children(props.Children...)),
|
||||||
|
h.Class("flex gap-1 items-center justify-center border p-4 rounded cursor-hover", props.Class),
|
||||||
|
h.If(props.Get != "", h.Get(props.Get)),
|
||||||
|
h.If(props.Target != "", h.HxTarget(props.Target)),
|
||||||
|
h.IfElse(props.Type != "", h.Type(props.Type), h.Type("button")),
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
|
||||||
|
return button
|
||||||
|
}
|
||||||
11
examples/chat/components/error.go
Normal file
11
examples/chat/components/error.go
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
package components
|
||||||
|
|
||||||
|
import "github.com/maddalax/htmgo/framework/h"
|
||||||
|
|
||||||
|
func FormError(error string) *h.Element {
|
||||||
|
return h.Div(
|
||||||
|
h.Id("form-error"),
|
||||||
|
h.Text(error),
|
||||||
|
h.If(error != "", h.Class("p-4 bg-rose-400 text-white rounded")),
|
||||||
|
)
|
||||||
|
}
|
||||||
55
examples/chat/components/input.go
Normal file
55
examples/chat/components/input.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"github.com/maddalax/htmgo/framework/hx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InputProps struct {
|
||||||
|
Id string
|
||||||
|
Label string
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
DefaultValue string
|
||||||
|
Placeholder string
|
||||||
|
Required bool
|
||||||
|
ValidationPath string
|
||||||
|
Error string
|
||||||
|
Children []h.Ren
|
||||||
|
}
|
||||||
|
|
||||||
|
func Input(props InputProps) *h.Element {
|
||||||
|
validation := h.If(props.ValidationPath != "", h.Children(
|
||||||
|
h.Post(props.ValidationPath, hx.BlurEvent),
|
||||||
|
h.Attribute("hx-swap", "innerHTML transition:true"),
|
||||||
|
h.Attribute("hx-target", "next div"),
|
||||||
|
))
|
||||||
|
|
||||||
|
if props.Type == "" {
|
||||||
|
props.Type = "text"
|
||||||
|
}
|
||||||
|
|
||||||
|
input := h.Input(
|
||||||
|
props.Type,
|
||||||
|
h.Class("border p-2 rounded focus:outline-none focus:ring focus:ring-slate-800"),
|
||||||
|
h.If(props.Name != "", h.Name(props.Name)),
|
||||||
|
h.If(props.Children != nil, h.Children(props.Children...)),
|
||||||
|
h.If(props.Required, h.Required()),
|
||||||
|
h.If(props.Placeholder != "", h.Placeholder(props.Placeholder)),
|
||||||
|
h.If(props.DefaultValue != "", h.Attribute("value", props.DefaultValue)),
|
||||||
|
validation,
|
||||||
|
)
|
||||||
|
|
||||||
|
wrapped := h.Div(
|
||||||
|
h.If(props.Id != "", h.Id(props.Id)),
|
||||||
|
h.Class("flex flex-col gap-1"),
|
||||||
|
h.If(props.Label != "", h.Label(h.Text(props.Label))),
|
||||||
|
input,
|
||||||
|
h.Div(
|
||||||
|
h.Id(props.Id+"-error"),
|
||||||
|
h.Class("text-red-500"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
}
|
||||||
12
examples/chat/go.mod
Normal file
12
examples/chat/go.mod
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
module chat
|
||||||
|
|
||||||
|
go 1.23.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/coder/websocket v1.8.12
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/maddalax/htmgo/framework v0.0.0-20241002032603-8b816e956692
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.23
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.4.0
|
||||||
|
)
|
||||||
24
examples/chat/go.sum
Normal file
24
examples/chat/go.sum
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||||
|
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/maddalax/htmgo/framework v0.0.0-20241001184532-9a5b92987701 h1:0Zk282axc1kPiuspLNzK5BJV7cQ5h2kPZHe54dznhYY=
|
||||||
|
github.com/maddalax/htmgo/framework v0.0.0-20241001184532-9a5b92987701/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
|
||||||
|
github.com/maddalax/htmgo/framework v0.0.0-20241002032603-8b816e956692 h1:NtLJ7GcD9hWvPYmombxC1SzVNgvnhLXWhZEQJZOstik=
|
||||||
|
github.com/maddalax/htmgo/framework v0.0.0-20241002032603-8b816e956692/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||||
|
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
31
examples/chat/internal/db/db.go
Normal file
31
examples/chat/internal/db/db.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.27.0
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DBTX interface {
|
||||||
|
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||||
|
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||||
|
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||||
|
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db DBTX) *Queries {
|
||||||
|
return &Queries{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Queries struct {
|
||||||
|
db DBTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||||
|
return &Queries{
|
||||||
|
db: tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
35
examples/chat/internal/db/models.go
Normal file
35
examples/chat/internal/db/models.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.27.0
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChatRoom struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
LastMessageSentAt sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
ID int64
|
||||||
|
ChatRoomID string
|
||||||
|
UserID int64
|
||||||
|
Username string
|
||||||
|
Message string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
SessionID string
|
||||||
|
}
|
||||||
25
examples/chat/internal/db/provider.go
Normal file
25
examples/chat/internal/db/provider.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
_ "embed"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed schema.sql
|
||||||
|
var ddl string
|
||||||
|
|
||||||
|
func Provide() *Queries {
|
||||||
|
db, err := sql.Open("sqlite3", "file:chat.db?cache=shared&_fk=1")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(context.Background(), ddl); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return New(db)
|
||||||
|
}
|
||||||
47
examples/chat/internal/db/queries.sql
Normal file
47
examples/chat/internal/db/queries.sql
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
-- name: CreateChatRoom :one
|
||||||
|
INSERT INTO chat_rooms (id, name, created_at, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING id, name, created_at, updated_at, last_message_sent_at;
|
||||||
|
|
||||||
|
-- name: InsertMessage :exec
|
||||||
|
INSERT INTO messages (chat_room_id, user_id, username, message, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING id, chat_room_id, user_id, username, message, created_at, updated_at;
|
||||||
|
|
||||||
|
-- name: UpdateChatRoomLastMessageSentAt :exec
|
||||||
|
UPDATE chat_rooms
|
||||||
|
SET last_message_sent_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: GetChatRoom :one
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
last_message_sent_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM chat_rooms
|
||||||
|
WHERE chat_rooms.id = ?;
|
||||||
|
|
||||||
|
-- name: CreateUser :one
|
||||||
|
INSERT INTO users (name, session_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING id, name, session_id, created_at, updated_at;
|
||||||
|
|
||||||
|
-- name: GetLastMessages :many
|
||||||
|
SELECT
|
||||||
|
messages.id,
|
||||||
|
messages.chat_room_id,
|
||||||
|
messages.user_id,
|
||||||
|
users.name AS user_name,
|
||||||
|
messages.message,
|
||||||
|
messages.created_at,
|
||||||
|
messages.updated_at
|
||||||
|
FROM messages
|
||||||
|
JOIN users ON messages.user_id = users.id
|
||||||
|
WHERE messages.chat_room_id = ?
|
||||||
|
ORDER BY messages.created_at
|
||||||
|
LIMIT ?;
|
||||||
|
|
||||||
|
-- name: GetUserBySessionId :one
|
||||||
|
SELECT * FROM users WHERE session_id = ?;
|
||||||
212
examples/chat/internal/db/queries.sql.go
Normal file
212
examples/chat/internal/db/queries.sql.go
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.27.0
|
||||||
|
// source: queries.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createChatRoom = `-- name: CreateChatRoom :one
|
||||||
|
INSERT INTO chat_rooms (id, name, created_at, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING id, name, created_at, updated_at, last_message_sent_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateChatRoomParams struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateChatRoomRow struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
LastMessageSentAt sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateChatRoom(ctx context.Context, arg CreateChatRoomParams) (CreateChatRoomRow, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createChatRoom, arg.ID, arg.Name)
|
||||||
|
var i CreateChatRoomRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.LastMessageSentAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const createUser = `-- name: CreateUser :one
|
||||||
|
INSERT INTO users (name, session_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING id, name, session_id, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateUserParams struct {
|
||||||
|
Name string
|
||||||
|
SessionID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateUserRow struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
SessionID string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createUser, arg.Name, arg.SessionID)
|
||||||
|
var i CreateUserRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.SessionID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChatRoom = `-- name: GetChatRoom :one
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
last_message_sent_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM chat_rooms
|
||||||
|
WHERE chat_rooms.id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetChatRoom(ctx context.Context, id string) (ChatRoom, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getChatRoom, id)
|
||||||
|
var i ChatRoom
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.LastMessageSentAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLastMessages = `-- name: GetLastMessages :many
|
||||||
|
SELECT
|
||||||
|
messages.id,
|
||||||
|
messages.chat_room_id,
|
||||||
|
messages.user_id,
|
||||||
|
users.name AS user_name,
|
||||||
|
messages.message,
|
||||||
|
messages.created_at,
|
||||||
|
messages.updated_at
|
||||||
|
FROM messages
|
||||||
|
JOIN users ON messages.user_id = users.id
|
||||||
|
WHERE messages.chat_room_id = ?
|
||||||
|
ORDER BY messages.created_at
|
||||||
|
LIMIT ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetLastMessagesParams struct {
|
||||||
|
ChatRoomID string
|
||||||
|
Limit int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetLastMessagesRow struct {
|
||||||
|
ID int64
|
||||||
|
ChatRoomID string
|
||||||
|
UserID int64
|
||||||
|
UserName string
|
||||||
|
Message string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetLastMessages(ctx context.Context, arg GetLastMessagesParams) ([]GetLastMessagesRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getLastMessages, arg.ChatRoomID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetLastMessagesRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetLastMessagesRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ChatRoomID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.UserName,
|
||||||
|
&i.Message,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserBySessionId = `-- name: GetUserBySessionId :one
|
||||||
|
SELECT id, name, created_at, updated_at, session_id FROM users WHERE session_id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUserBySessionId(ctx context.Context, sessionID string) (User, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getUserBySessionId, sessionID)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.SessionID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertMessage = `-- name: InsertMessage :exec
|
||||||
|
INSERT INTO messages (chat_room_id, user_id, username, message, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING id, chat_room_id, user_id, username, message, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertMessageParams struct {
|
||||||
|
ChatRoomID string
|
||||||
|
UserID int64
|
||||||
|
Username string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertMessage(ctx context.Context, arg InsertMessageParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, insertMessage,
|
||||||
|
arg.ChatRoomID,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Username,
|
||||||
|
arg.Message,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateChatRoomLastMessageSentAt = `-- name: UpdateChatRoomLastMessageSentAt :exec
|
||||||
|
UPDATE chat_rooms
|
||||||
|
SET last_message_sent_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) UpdateChatRoomLastMessageSentAt(ctx context.Context, id string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, updateChatRoomLastMessageSentAt, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
33
examples/chat/internal/db/schema.sql
Normal file
33
examples/chat/internal/db/schema.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS users
|
||||||
|
(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
session_id TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_rooms
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
last_message_sent_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messages
|
||||||
|
(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
chat_room_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (chat_room_id) REFERENCES chat_rooms (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_chat_room_id ON messages (chat_room_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_user_id ON messages (user_id);
|
||||||
17
examples/chat/internal/embedded/os.go
Normal file
17
examples/chat/internal/embedded/os.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package embedded
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OsFs struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (receiver OsFs) Open(name string) (fs.File, error) {
|
||||||
|
return os.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOsFs() OsFs {
|
||||||
|
return OsFs{}
|
||||||
|
}
|
||||||
25
examples/chat/internal/routine/goroutine.go
Normal file
25
examples/chat/internal/routine/goroutine.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package routine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DebugLongRunning(name string, f func()) {
|
||||||
|
now := time.Now()
|
||||||
|
done := make(chan struct{}, 1)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Second * 5)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
elapsed := time.Since(now).Milliseconds()
|
||||||
|
fmt.Printf("function %s has not finished after %dms\n", name, elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
f()
|
||||||
|
done <- struct{}{}
|
||||||
|
}
|
||||||
54
examples/chat/main.go
Normal file
54
examples/chat/main.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"chat/__htmgo"
|
||||||
|
"chat/chat"
|
||||||
|
"chat/internal/db"
|
||||||
|
"chat/ws"
|
||||||
|
"fmt"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"github.com/maddalax/htmgo/framework/service"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
locator := service.NewLocator()
|
||||||
|
|
||||||
|
service.Set[db.Queries](locator, service.Singleton, db.Provide)
|
||||||
|
service.Set[ws.SocketManager](locator, service.Singleton, func() *ws.SocketManager {
|
||||||
|
return ws.NewSocketManager()
|
||||||
|
})
|
||||||
|
|
||||||
|
chatManager := chat.NewManager(locator)
|
||||||
|
go chatManager.StartListener()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
count := runtime.NumGoroutine()
|
||||||
|
fmt.Printf("goroutines: %d\n", count)
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
h.Start(h.AppOpts{
|
||||||
|
ServiceLocator: locator,
|
||||||
|
LiveReload: true,
|
||||||
|
Register: func(app *h.App) {
|
||||||
|
sub, err := fs.Sub(GetStaticAssets(), "assets/dist")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.FileServerFS(sub)
|
||||||
|
|
||||||
|
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
|
||||||
|
app.Router.Handle("/ws/chat/{id}", ws.Handle())
|
||||||
|
|
||||||
|
__htmgo.Register(app.Router)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
167
examples/chat/pages/chat.$id.go
Normal file
167
examples/chat/pages/chat.$id.go
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"chat/chat"
|
||||||
|
"chat/internal/db"
|
||||||
|
"chat/partials"
|
||||||
|
"fmt"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"github.com/maddalax/htmgo/framework/js"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ChatRoom(ctx *h.RequestContext) *h.Page {
|
||||||
|
roomId := chi.URLParam(ctx.Request, "id")
|
||||||
|
return h.NewPage(
|
||||||
|
RootPage(
|
||||||
|
h.Div(
|
||||||
|
h.JoinExtensions(
|
||||||
|
h.TriggerChildren(),
|
||||||
|
h.HxExtension("ws"),
|
||||||
|
),
|
||||||
|
|
||||||
|
h.Attribute("sse-connect", fmt.Sprintf("/ws/chat/%s", roomId)),
|
||||||
|
|
||||||
|
h.HxOnSseOpen(
|
||||||
|
js.ConsoleLog("Connected to chat room"),
|
||||||
|
),
|
||||||
|
|
||||||
|
h.HxOnSseError(
|
||||||
|
js.EvalJs(fmt.Sprintf(`
|
||||||
|
const reason = e.detail.event.data
|
||||||
|
if(['invalid room', 'no session', 'invalid user'].includes(reason)) {
|
||||||
|
window.location.href = '/?roomId=%s';
|
||||||
|
} else if(e.detail.event.code === 1011) {
|
||||||
|
window.location.reload()
|
||||||
|
} else if (e.detail.event.code === 1008 || e.detail.event.code === 1006) {
|
||||||
|
window.location.href = '/?roomId=%s';
|
||||||
|
} else {
|
||||||
|
console.error('Connection closed:', e.detail.event)
|
||||||
|
}
|
||||||
|
`, roomId, roomId)),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Adjusted flex properties for responsive layout
|
||||||
|
h.Class("flex flex-row h-screen bg-neutral-100"),
|
||||||
|
|
||||||
|
// Collapse Button for mobile
|
||||||
|
CollapseButton(),
|
||||||
|
|
||||||
|
// Sidebar for connected users
|
||||||
|
UserSidebar(),
|
||||||
|
|
||||||
|
h.Div(
|
||||||
|
// Adjusted to fill height and width
|
||||||
|
h.Class("flex flex-col h-full w-full bg-white p-4 overflow-hidden"),
|
||||||
|
|
||||||
|
// Room name at the top, fixed
|
||||||
|
CachedRoomHeader(ctx),
|
||||||
|
|
||||||
|
h.HxAfterSseMessage(
|
||||||
|
js.EvalJsOnSibling("#messages",
|
||||||
|
`element.scrollTop = element.scrollHeight;`),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Chat Messages
|
||||||
|
h.Div(
|
||||||
|
h.Id("messages"),
|
||||||
|
// Adjusted flex properties and removed max-width
|
||||||
|
h.Class("flex flex-col gap-4 mb-4 overflow-auto flex-grow w-full pt-[50px]"),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Chat Input at the bottom
|
||||||
|
Form(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var CachedRoomHeader = h.CachedPerKeyT(time.Hour, func(ctx *h.RequestContext) (string, h.GetElementFunc) {
|
||||||
|
roomId := chi.URLParam(ctx.Request, "id")
|
||||||
|
return roomId, func() *h.Element {
|
||||||
|
return roomNameHeader(ctx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
func roomNameHeader(ctx *h.RequestContext) *h.Element {
|
||||||
|
roomId := chi.URLParam(ctx.Request, "id")
|
||||||
|
service := chat.NewService(ctx.ServiceLocator())
|
||||||
|
room, err := service.GetRoom(roomId)
|
||||||
|
if err != nil {
|
||||||
|
return h.Div()
|
||||||
|
}
|
||||||
|
return h.Div(
|
||||||
|
h.Class("bg-neutral-700 text-white p-3 shadow-sm w-full fixed top-0 left-0 flex justify-center z-10"),
|
||||||
|
h.H2F(room.Name, h.Class("text-lg font-bold")),
|
||||||
|
h.Div(
|
||||||
|
h.Class("absolute right-5 top-3 cursor-pointer"),
|
||||||
|
h.Text("Share"),
|
||||||
|
h.OnClick(
|
||||||
|
js.EvalJs(`
|
||||||
|
alert("Share this url with your friends:\n " + window.location.href)
|
||||||
|
`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserSidebar() *h.Element {
|
||||||
|
return h.Div(
|
||||||
|
h.Class("sidebar h-full pt-[67px] min-w-48 w-48 bg-neutral-200 p-4 flex-col justify-between gap-3 rounded-l-lg hidden md:flex"),
|
||||||
|
h.Div(
|
||||||
|
h.H3F("Connected Users", h.Class("text-lg font-bold")),
|
||||||
|
chat.ConnectedUsers(make([]db.User, 0), ""),
|
||||||
|
),
|
||||||
|
h.A(
|
||||||
|
h.Class("cursor-pointer"),
|
||||||
|
h.Href("/"),
|
||||||
|
h.Text("Leave Room"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CollapseButton() *h.Element {
|
||||||
|
return h.Div(
|
||||||
|
h.Class("fixed top-0 left-4 md:hidden z-50"), // Always visible on mobile
|
||||||
|
h.Button(
|
||||||
|
h.Class("p-2 text-2xl bg-neutral-700 text-white rounded-md"), // Styling the button
|
||||||
|
h.OnClick(
|
||||||
|
js.EvalJs(`
|
||||||
|
const sidebar = document.querySelector('.sidebar');
|
||||||
|
sidebar.classList.toggle('hidden');
|
||||||
|
sidebar.classList.toggle('flex');
|
||||||
|
`),
|
||||||
|
),
|
||||||
|
h.UnsafeRaw("☰"), // The icon for collapsing the sidebar
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MessageInput() *h.Element {
|
||||||
|
return h.Input("text",
|
||||||
|
h.Id("message-input"),
|
||||||
|
h.Required(),
|
||||||
|
h.Class("p-4 rounded-md border border-slate-200 w-full focus:outline-none focus:ring focus:ring-slate-200"),
|
||||||
|
h.Name("message"),
|
||||||
|
h.MaxLength(1000),
|
||||||
|
h.Placeholder("Type a message..."),
|
||||||
|
h.HxAfterSseMessage(
|
||||||
|
js.SetValue(""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Form() *h.Element {
|
||||||
|
return h.Div(
|
||||||
|
h.Class("flex gap-4 items-center"),
|
||||||
|
h.Form(
|
||||||
|
h.NoSwap(),
|
||||||
|
h.PostPartial(partials.SendMessage),
|
||||||
|
h.Attribute("ws-send", ""),
|
||||||
|
h.Class("flex flex-grow"),
|
||||||
|
MessageInput(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
84
examples/chat/pages/index.go
Normal file
84
examples/chat/pages/index.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"chat/components"
|
||||||
|
"chat/partials"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ChatAppFirstScreen(ctx *h.RequestContext) *h.Page {
|
||||||
|
return h.NewPage(
|
||||||
|
RootPage(
|
||||||
|
h.Div(
|
||||||
|
h.Class("flex flex-col items-center justify-center min-h-screen bg-neutral-100"),
|
||||||
|
h.Div(
|
||||||
|
h.Class("bg-white p-8 rounded-lg shadow-lg w-full max-w-md"),
|
||||||
|
h.H2F("htmgo chat", h.Class("text-3xl font-bold text-center mb-6")),
|
||||||
|
h.Form(
|
||||||
|
h.Attribute("hx-swap", "none"),
|
||||||
|
h.PostPartial(partials.CreateOrJoinRoom),
|
||||||
|
h.Class("flex flex-col gap-6"),
|
||||||
|
|
||||||
|
// Username input at the top
|
||||||
|
components.Input(components.InputProps{
|
||||||
|
Id: "username",
|
||||||
|
Name: "username",
|
||||||
|
Label: "Username",
|
||||||
|
Required: true,
|
||||||
|
Children: []h.Ren{
|
||||||
|
h.Attribute("autocomplete", "off"),
|
||||||
|
h.MaxLength(15),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Single box for Create or Join a Chat Room
|
||||||
|
h.Div(
|
||||||
|
h.Class("p-4 border border-gray-300 rounded-md flex flex-col gap-6"),
|
||||||
|
|
||||||
|
// Create New Chat Room input
|
||||||
|
components.Input(components.InputProps{
|
||||||
|
Name: "new-chat-room",
|
||||||
|
Label: "Create a new chat room",
|
||||||
|
Placeholder: "Enter chat room name",
|
||||||
|
Children: []h.Ren{
|
||||||
|
h.Attribute("autocomplete", "off"),
|
||||||
|
h.MaxLength(20),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// OR divider
|
||||||
|
h.Div(
|
||||||
|
h.Class("flex items-center justify-center gap-4"),
|
||||||
|
h.Div(h.Class("border-t border-gray-300 flex-grow")),
|
||||||
|
h.P(h.Text("OR"), h.Class("text-gray-500")),
|
||||||
|
h.Div(h.Class("border-t border-gray-300 flex-grow")),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Join Chat Room input
|
||||||
|
components.Input(components.InputProps{
|
||||||
|
Id: "join-chat-room",
|
||||||
|
Name: "join-chat-room",
|
||||||
|
Label: "Join an existing chat room",
|
||||||
|
Placeholder: "Enter chat room ID",
|
||||||
|
DefaultValue: ctx.QueryParam("roomId"),
|
||||||
|
Children: []h.Ren{
|
||||||
|
h.Attribute("autocomplete", "off"),
|
||||||
|
h.MaxLength(100),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
components.FormError(""),
|
||||||
|
|
||||||
|
// Submit button at the bottom
|
||||||
|
components.PrimaryButton(components.ButtonProps{
|
||||||
|
Type: "submit",
|
||||||
|
Text: "Submit",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
26
examples/chat/pages/root.go
Normal file
26
examples/chat/pages/root.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RootPage(children ...h.Ren) h.Ren {
|
||||||
|
extensions := h.BaseExtensions()
|
||||||
|
return h.Html(
|
||||||
|
h.HxExtension(extensions),
|
||||||
|
h.Meta("viewport", "width=device-width, initial-scale=1"),
|
||||||
|
h.Meta("title", "htmgo chat example"),
|
||||||
|
h.Meta("charset", "utf-8"),
|
||||||
|
h.Meta("author", "htmgo"),
|
||||||
|
h.Head(
|
||||||
|
h.Link("/public/main.css", "stylesheet"),
|
||||||
|
h.Script("/public/htmgo.js"),
|
||||||
|
),
|
||||||
|
h.Body(
|
||||||
|
h.Div(
|
||||||
|
h.Class("flex flex-col gap-2 bg-white h-full"),
|
||||||
|
h.Fragment(children...),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
35
examples/chat/partials/chat.go
Normal file
35
examples/chat/partials/chat.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package partials
|
||||||
|
|
||||||
|
import (
|
||||||
|
"chat/components"
|
||||||
|
"chat/ws"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"github.com/maddalax/htmgo/framework/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SendMessage(ctx *h.RequestContext) *h.Partial {
|
||||||
|
locator := ctx.ServiceLocator()
|
||||||
|
socketManager := service.Get[ws.SocketManager](locator)
|
||||||
|
|
||||||
|
sessionCookie, err := ctx.Request.Cookie("session_id")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return h.SwapPartial(ctx, components.FormError("Session not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
message := ctx.Request.FormValue("message")
|
||||||
|
|
||||||
|
if message == "" {
|
||||||
|
return h.SwapPartial(ctx, components.FormError("Message is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(message) > 200 {
|
||||||
|
return h.SwapPartial(ctx, components.FormError("Message is too long"))
|
||||||
|
}
|
||||||
|
|
||||||
|
socketManager.OnMessage(sessionCookie.Value, map[string]any{
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
|
||||||
|
return h.EmptyPartial()
|
||||||
|
}
|
||||||
73
examples/chat/partials/index.go
Normal file
73
examples/chat/partials/index.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
package partials
|
||||||
|
|
||||||
|
import (
|
||||||
|
"chat/chat"
|
||||||
|
"chat/components"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateOrJoinRoom(ctx *h.RequestContext) *h.Partial {
|
||||||
|
locator := ctx.ServiceLocator()
|
||||||
|
service := chat.NewService(locator)
|
||||||
|
|
||||||
|
chatRoomId := ctx.Request.FormValue("join-chat-room")
|
||||||
|
username := ctx.Request.FormValue("username")
|
||||||
|
|
||||||
|
if username == "" {
|
||||||
|
return h.SwapPartial(ctx, components.FormError("Username is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(username) > 15 {
|
||||||
|
return h.SwapPartial(ctx, components.FormError("Username is too long"))
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := service.CreateUser(username)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return h.SwapPartial(ctx, components.FormError("Failed to create user"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var redirect = func(path string) *h.Partial {
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: "session_id",
|
||||||
|
Value: user.SessionID,
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Now().Add(24 * 30 * time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.RedirectPartialWithHeaders(
|
||||||
|
path,
|
||||||
|
h.NewHeaders(
|
||||||
|
"Set-Cookie", cookie.String(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if chatRoomId != "" {
|
||||||
|
room, _ := service.GetRoom(chatRoomId)
|
||||||
|
if room == nil {
|
||||||
|
return h.SwapPartial(ctx, components.FormError("Room not found"))
|
||||||
|
} else {
|
||||||
|
return redirect("/chat/" + chatRoomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatRoomName := ctx.Request.FormValue("new-chat-room")
|
||||||
|
|
||||||
|
if len(chatRoomName) > 20 {
|
||||||
|
return h.SwapPartial(ctx, components.FormError("Chat room name is too long"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if chatRoomName != "" {
|
||||||
|
room, _ := service.CreateRoom(chatRoomName)
|
||||||
|
if room == nil {
|
||||||
|
return h.SwapPartial(ctx, components.FormError("Failed to create room"))
|
||||||
|
} else {
|
||||||
|
return redirect("/chat/" + room.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.SwapPartial(ctx, components.FormError("Create a new room or join an existing one"))
|
||||||
|
}
|
||||||
9
examples/chat/sqlc.yaml
Normal file
9
examples/chat/sqlc.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
version: "2"
|
||||||
|
sql:
|
||||||
|
- schema: "internal/db/schema.sql"
|
||||||
|
queries: "internal/db/queries.sql"
|
||||||
|
engine: "sqlite"
|
||||||
|
gen:
|
||||||
|
go:
|
||||||
|
package: "db"
|
||||||
|
out: "internal/db"
|
||||||
5
examples/chat/tailwind.config.js
Normal file
5
examples/chat/tailwind.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["**/*.go"],
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
112
examples/chat/ws/handler.go
Normal file
112
examples/chat/ws/handler.go
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"github.com/maddalax/htmgo/framework/service"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Handle() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Set the necessary headers
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*") // Optional for CORS
|
||||||
|
|
||||||
|
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
|
||||||
|
locator := cc.ServiceLocator()
|
||||||
|
manager := service.Get[SocketManager](locator)
|
||||||
|
|
||||||
|
sessionCookie, _ := r.Cookie("session_id")
|
||||||
|
sessionId := ""
|
||||||
|
|
||||||
|
if sessionCookie != nil {
|
||||||
|
sessionId = sessionCookie.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
/*
|
||||||
|
Large buffer in case the client disconnects while we are writing
|
||||||
|
we don't want to block the writer
|
||||||
|
*/
|
||||||
|
done := make(chan bool, 1000)
|
||||||
|
writer := make(WriterChan, 1000)
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This goroutine is responsible for writing messages to the client
|
||||||
|
*/
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer manager.Disconnect(sessionId)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
fmt.Printf("empting channels\n")
|
||||||
|
for len(writer) > 0 {
|
||||||
|
<-writer
|
||||||
|
}
|
||||||
|
for len(done) > 0 {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-done:
|
||||||
|
fmt.Printf("closing connection: \n")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
manager.Ping(sessionId)
|
||||||
|
case message := <-writer:
|
||||||
|
_, err := fmt.Fprintf(w, message)
|
||||||
|
if err != nil {
|
||||||
|
done <- true
|
||||||
|
} else {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This goroutine is responsible for adding the client to the room
|
||||||
|
*/
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if sessionId == "" {
|
||||||
|
manager.writeCloseRaw(writer, "no session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roomId := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
if roomId == "" {
|
||||||
|
slog.Error("invalid room", slog.String("room_id", roomId))
|
||||||
|
manager.writeCloseRaw(writer, "invalid room")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.Add(roomId, sessionId, writer, done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
233
examples/chat/ws/manager.go
Normal file
233
examples/chat/ws/manager.go
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"chat/internal/routine"
|
||||||
|
"fmt"
|
||||||
|
"github.com/puzpuzpuz/xsync/v3"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EventType string
|
||||||
|
type WriterChan chan string
|
||||||
|
type DoneChan chan bool
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConnectedEvent EventType = "connected"
|
||||||
|
DisconnectedEvent EventType = "disconnected"
|
||||||
|
MessageEvent EventType = "message"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SocketEvent struct {
|
||||||
|
Id string
|
||||||
|
RoomId string
|
||||||
|
Type EventType
|
||||||
|
Payload map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type CloseEvent struct {
|
||||||
|
Code int
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SocketConnection struct {
|
||||||
|
Id string
|
||||||
|
RoomId string
|
||||||
|
Done DoneChan
|
||||||
|
Writer WriterChan
|
||||||
|
}
|
||||||
|
|
||||||
|
type SocketManager struct {
|
||||||
|
sockets *xsync.MapOf[string, *xsync.MapOf[string, SocketConnection]]
|
||||||
|
idToRoom *xsync.MapOf[string, string]
|
||||||
|
listeners []chan SocketEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSocketManager() *SocketManager {
|
||||||
|
return &SocketManager{
|
||||||
|
sockets: xsync.NewMapOf[string, *xsync.MapOf[string, SocketConnection]](),
|
||||||
|
idToRoom: xsync.NewMapOf[string, string](),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) ForEachSocket(roomId string, cb func(conn SocketConnection)) {
|
||||||
|
sockets, ok := manager.sockets.Load(roomId)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sockets.Range(func(id string, conn SocketConnection) bool {
|
||||||
|
cb(conn)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) Listen(listener chan SocketEvent) {
|
||||||
|
if manager.listeners == nil {
|
||||||
|
manager.listeners = make([]chan SocketEvent, 0)
|
||||||
|
}
|
||||||
|
if listener != nil {
|
||||||
|
manager.listeners = append(manager.listeners, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) dispatch(event SocketEvent) {
|
||||||
|
fmt.Printf("dispatching event: %s\n", event.Type)
|
||||||
|
done := make(chan struct{}, 1)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
fmt.Printf("dispatched event: %s\n", event.Type)
|
||||||
|
return
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
fmt.Printf("havent dispatched event after 5s, chan blocked: %s\n", event.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for _, listener := range manager.listeners {
|
||||||
|
listener <- event
|
||||||
|
}
|
||||||
|
done <- struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) OnMessage(id string, message map[string]any) {
|
||||||
|
socket := manager.Get(id)
|
||||||
|
if socket == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manager.dispatch(SocketEvent{
|
||||||
|
Id: id,
|
||||||
|
Type: MessageEvent,
|
||||||
|
Payload: message,
|
||||||
|
RoomId: socket.RoomId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) Add(roomId string, id string, writer WriterChan, done DoneChan) {
|
||||||
|
manager.idToRoom.Store(id, roomId)
|
||||||
|
|
||||||
|
sockets, ok := manager.sockets.LoadOrCompute(roomId, func() *xsync.MapOf[string, SocketConnection] {
|
||||||
|
return xsync.NewMapOf[string, SocketConnection]()
|
||||||
|
})
|
||||||
|
|
||||||
|
sockets.Store(id, SocketConnection{
|
||||||
|
Id: id,
|
||||||
|
Writer: writer,
|
||||||
|
RoomId: roomId,
|
||||||
|
Done: done,
|
||||||
|
})
|
||||||
|
|
||||||
|
s, ok := sockets.Load(id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.dispatch(SocketEvent{
|
||||||
|
Id: s.Id,
|
||||||
|
Type: ConnectedEvent,
|
||||||
|
RoomId: s.RoomId,
|
||||||
|
Payload: map[string]any{},
|
||||||
|
})
|
||||||
|
|
||||||
|
fmt.Printf("User %s connected to %s\n", id, roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) OnClose(id string) {
|
||||||
|
socket := manager.Get(id)
|
||||||
|
if socket == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manager.dispatch(SocketEvent{
|
||||||
|
Id: id,
|
||||||
|
Type: DisconnectedEvent,
|
||||||
|
RoomId: socket.RoomId,
|
||||||
|
Payload: map[string]any{},
|
||||||
|
})
|
||||||
|
manager.sockets.Delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) CloseWithMessage(id string, message string) {
|
||||||
|
conn := manager.Get(id)
|
||||||
|
if conn != nil {
|
||||||
|
defer manager.OnClose(id)
|
||||||
|
manager.writeText(*conn, "error", message)
|
||||||
|
conn.Done <- true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) Disconnect(id string) {
|
||||||
|
conn := manager.Get(id)
|
||||||
|
if conn != nil {
|
||||||
|
manager.OnClose(id)
|
||||||
|
conn.Done <- true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) Get(id string) *SocketConnection {
|
||||||
|
roomId, ok := manager.idToRoom.Load(id)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sockets, ok := manager.sockets.Load(roomId)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
conn, ok := sockets.Load(id)
|
||||||
|
return &conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) Ping(id string) {
|
||||||
|
conn := manager.Get(id)
|
||||||
|
if conn != nil {
|
||||||
|
manager.writeText(*conn, "ping", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) writeCloseRaw(writer WriterChan, message string) {
|
||||||
|
manager.writeTextRaw(writer, "close", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) writeTextRaw(writer WriterChan, event string, message string) {
|
||||||
|
routine.DebugLongRunning("writeTextRaw", func() {
|
||||||
|
timeout := 3 * time.Second
|
||||||
|
data := ""
|
||||||
|
if event != "" {
|
||||||
|
data = fmt.Sprintf("event: %s\ndata: %s\n\n", event, message)
|
||||||
|
} else {
|
||||||
|
data = fmt.Sprintf("data: %s\n\n", message)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case writer <- data:
|
||||||
|
case <-time.After(timeout):
|
||||||
|
fmt.Printf("could not send %s to channel after %s\n", data, timeout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) writeText(socket SocketConnection, event string, message string) {
|
||||||
|
if socket.Writer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manager.writeTextRaw(socket.Writer, event, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) BroadcastText(roomId string, message string, predicate func(conn SocketConnection) bool) {
|
||||||
|
sockets, ok := manager.sockets.Load(roomId)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sockets.Range(func(id string, conn SocketConnection) bool {
|
||||||
|
if predicate(conn) {
|
||||||
|
manager.writeText(conn, "", message)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) SendText(id string, message string) {
|
||||||
|
conn := manager.Get(id)
|
||||||
|
if conn != nil {
|
||||||
|
manager.writeText(*conn, "", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,8 @@ type InputProps struct {
|
||||||
Name string
|
Name string
|
||||||
Type string
|
Type string
|
||||||
DefaultValue string
|
DefaultValue string
|
||||||
|
Placeholder string
|
||||||
|
Required bool
|
||||||
ValidationPath string
|
ValidationPath string
|
||||||
Children []h.Ren
|
Children []h.Ren
|
||||||
}
|
}
|
||||||
|
|
@ -22,19 +24,24 @@ func Input(props InputProps) h.Ren {
|
||||||
h.Attribute("hx-target", "next div"),
|
h.Attribute("hx-target", "next div"),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
if props.Type == "" {
|
||||||
|
props.Type = "text"
|
||||||
|
}
|
||||||
|
|
||||||
input := h.Input(
|
input := h.Input(
|
||||||
props.Type,
|
props.Type,
|
||||||
h.Class("border p-2 rounded"),
|
h.Class("border p-2 rounded"),
|
||||||
h.If(props.Id != "", h.Id(props.Id)),
|
h.If(props.Id != "", h.Id(props.Id)),
|
||||||
h.If(props.Name != "", h.Name(props.Name)),
|
h.If(props.Name != "", h.Name(props.Name)),
|
||||||
h.If(props.Children != nil, h.Children(props.Children...)),
|
h.If(props.Children != nil, h.Children(props.Children...)),
|
||||||
|
h.If(props.Required, h.Required()),
|
||||||
h.If(props.DefaultValue != "", h.Attribute("value", props.DefaultValue)),
|
h.If(props.DefaultValue != "", h.Attribute("value", props.DefaultValue)),
|
||||||
validation,
|
validation,
|
||||||
)
|
)
|
||||||
|
|
||||||
wrapped := h.Div(
|
wrapped := h.Div(
|
||||||
h.Class("flex flex-col gap-1"),
|
h.Class("flex flex-col gap-1"),
|
||||||
h.If(props.Label != "", h.Label(props.Label)),
|
h.If(props.Label != "", h.Label(h.Text(props.Label))),
|
||||||
input,
|
input,
|
||||||
h.Div(h.Class("text-red-500")),
|
h.Div(h.Class("text-red-500")),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
4
framework/assets/dist/htmgo.js
vendored
4
framework/assets/dist/htmgo.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -6,6 +6,7 @@ import "./htmxextensions/response-targets";
|
||||||
import "./htmxextensions/mutation-error";
|
import "./htmxextensions/mutation-error";
|
||||||
import "./htmxextensions/livereload"
|
import "./htmxextensions/livereload"
|
||||||
import "./htmxextensions/htmgo";
|
import "./htmxextensions/htmgo";
|
||||||
|
import "./htmxextensions/sse"
|
||||||
|
|
||||||
function watchUrl(callback: (oldUrl: string, newUrl: string) => void) {
|
function watchUrl(callback: (oldUrl: string, newUrl: string) => void) {
|
||||||
let lastUrl = window.location.href;
|
let lastUrl = window.location.href;
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ htmx.defineExtension("debug", {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
onEvent: function (name, evt) {
|
onEvent: function (name, evt) {
|
||||||
if (console.debug) {
|
if (console.debug) {
|
||||||
console.debug(name);
|
console.debug(name, evt);
|
||||||
} else if (console) {
|
} else if (console) {
|
||||||
console.log("DEBUG:", name);
|
console.log("DEBUG:", name, evt);
|
||||||
} else {
|
} else {
|
||||||
// noop
|
// noop
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import htmx from "htmx.org";
|
import htmx from "htmx.org";
|
||||||
|
|
||||||
const evalFuncRegex = /__eval_[A-Za-z0-9]+\(\)/gm
|
const evalFuncRegex =/__eval_[A-Za-z0-9]+\([a-z]+\)/gm
|
||||||
|
|
||||||
htmx.defineExtension("htmgo", {
|
htmx.defineExtension("htmgo", {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
@ -11,14 +11,15 @@ htmx.defineExtension("htmgo", {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function removeAssociatedScripts(element: HTMLElement) {
|
export function removeAssociatedScripts(element: HTMLElement) {
|
||||||
const attributes = Array.from(element.attributes)
|
const attributes = Array.from(element.attributes)
|
||||||
for (let attribute of attributes) {
|
for (let attribute of attributes) {
|
||||||
const matches = attribute.value.match(evalFuncRegex) || []
|
const matches = attribute.value.match(evalFuncRegex) || []
|
||||||
for (let match of matches) {
|
for (let match of matches) {
|
||||||
const id = match.replace("()", "")
|
const id = match.replace("()", "").replace("(this)", "").replace(";", "")
|
||||||
const ele = document.getElementById(id)
|
const ele = document.getElementById(id)
|
||||||
if(ele && ele.tagName === "SCRIPT") {
|
if(ele && ele.tagName === "SCRIPT") {
|
||||||
|
console.debug("removing associated script with id", id)
|
||||||
ele.remove()
|
ele.remove()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
72
framework/assets/js/htmxextensions/sse.ts
Normal file
72
framework/assets/js/htmxextensions/sse.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import htmx from 'htmx.org'
|
||||||
|
import {removeAssociatedScripts} from "./htmgo";
|
||||||
|
|
||||||
|
let api : any = null;
|
||||||
|
let processed = new Set<string>()
|
||||||
|
|
||||||
|
htmx.defineExtension("sse", {
|
||||||
|
init: function (apiRef) {
|
||||||
|
api = apiRef;
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
onEvent: function (name, evt) {
|
||||||
|
const target = evt.target;
|
||||||
|
if(!(target instanceof HTMLElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if(name === 'htmx:beforeCleanupElement') {
|
||||||
|
removeAssociatedScripts(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(name === 'htmx:beforeProcessNode') {
|
||||||
|
const elements = document.querySelectorAll('[sse-connect]');
|
||||||
|
for (let element of Array.from(elements)) {
|
||||||
|
const url = element.getAttribute("sse-connect")!;
|
||||||
|
if(url && !processed.has(url)) {
|
||||||
|
connectEventSource(element, url)
|
||||||
|
processed.add(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function connectEventSource(ele: Element, url: string) {
|
||||||
|
if(!url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.info('Connecting to EventSource', url)
|
||||||
|
const eventSource = new EventSource(url);
|
||||||
|
|
||||||
|
eventSource.addEventListener("close", function(event) {
|
||||||
|
htmx.trigger(ele, "htmx:sseClose", {event: event});
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.onopen = function(event) {
|
||||||
|
htmx.trigger(ele, "htmx:sseOpen", {event: event});
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.onerror = function(event) {
|
||||||
|
htmx.trigger(ele, "htmx:sseError", {event: event});
|
||||||
|
if (eventSource.readyState == EventSource.CLOSED) {
|
||||||
|
htmx.trigger(ele, "htmx:sseClose", {event: event});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.onmessage = function(event) {
|
||||||
|
const settleInfo = api.makeSettleInfo(ele);
|
||||||
|
htmx.trigger(ele, "htmx:sseBeforeMessage", {event: event});
|
||||||
|
const response = event.data
|
||||||
|
const fragment = api.makeFragment(response) as DocumentFragment;
|
||||||
|
const children = Array.from(fragment.children);
|
||||||
|
for (let child of children) {
|
||||||
|
api.oobSwap(api.getAttributeValue(child, 'hx-swap-oob') || 'true', child, settleInfo);
|
||||||
|
// support htmgo eval__ scripts
|
||||||
|
if(child.tagName === 'SCRIPT' && child.id.startsWith("__eval")) {
|
||||||
|
document.body.appendChild(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
htmx.trigger(ele, "htmx:sseAfterMessage", {event: event});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,62 @@
|
||||||
import htmx, { HtmxSettleInfo, HtmxSwapStyle } from "htmx.org";
|
import htmx, {HtmxSettleInfo, HtmxSwapStyle} from "htmx.org";
|
||||||
|
|
||||||
|
function kebabEventName(str: string) {
|
||||||
|
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ignoredEvents = ['htmx:beforeProcessNode', 'htmx:afterProcessNode', 'htmx:beforeSwap', 'htmx:afterSwap', 'htmx:beforeOnLoad', 'htmx:afterOnLoad', 'htmx:configRequest', 'htmx:configResponse', 'htmx:responseError'];
|
||||||
|
|
||||||
|
function makeEvent(eventName: string, detail: any) {
|
||||||
|
let evt
|
||||||
|
if (window.CustomEvent && typeof window.CustomEvent === 'function') {
|
||||||
|
// TODO: `composed: true` here is a hack to make global event handlers work with events in shadow DOM
|
||||||
|
evt = new CustomEvent(eventName, { bubbles: false, cancelable: true, composed: true, detail })
|
||||||
|
} else {
|
||||||
|
evt = document.createEvent('CustomEvent')
|
||||||
|
evt.initCustomEvent(eventName, true, true, detail)
|
||||||
|
}
|
||||||
|
return evt
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerChildren(target: HTMLElement, name: string, event: CustomEvent, triggered: Set<HTMLElement>) {
|
||||||
|
if(ignoredEvents.includes(name)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (target && target.children) {
|
||||||
|
Array.from(target.children).forEach((e) => {
|
||||||
|
const kehab = kebabEventName(name);
|
||||||
|
const eventName = kehab.replace("htmx:", "hx-on::")
|
||||||
|
if (!triggered.has(e as HTMLElement)) {
|
||||||
|
if(e.hasAttribute(eventName)) {
|
||||||
|
const newEvent = makeEvent(eventName.replace("hx-on::", "htmx:"), event.detail)
|
||||||
|
newEvent.detail.meta = 'trigger-children'
|
||||||
|
e.dispatchEvent(newEvent)
|
||||||
|
triggered.add(e as HTMLElement);
|
||||||
|
}
|
||||||
|
if (e.children) {
|
||||||
|
triggerChildren(e as HTMLElement, name, event, triggered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
htmx.defineExtension("trigger-children", {
|
htmx.defineExtension("trigger-children", {
|
||||||
onEvent: (name, evt: Event | CustomEvent) => {
|
onEvent: (name, evt: Event | CustomEvent) => {
|
||||||
if (!(evt instanceof CustomEvent)) {
|
if (!(evt instanceof CustomEvent)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const target = evt.detail.target as HTMLElement;
|
if(evt.detail.meta === 'trigger-children') {
|
||||||
if (target && target.children) {
|
return false;
|
||||||
Array.from(target.children).forEach((e) => {
|
|
||||||
htmx.trigger(e, name, null);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
const triggered = new Set<HTMLElement>();
|
||||||
|
const target = evt.target as HTMLElement || evt.detail.target as HTMLElement;
|
||||||
|
triggerChildren(target, name, evt, triggered);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
init: function (api: any): void {},
|
init: function (api: any): void {
|
||||||
|
},
|
||||||
transformResponse: function (
|
transformResponse: function (
|
||||||
text: string,
|
text: string,
|
||||||
xhr: XMLHttpRequest,
|
xhr: XMLHttpRequest,
|
||||||
|
|
@ -36,7 +79,8 @@ htmx.defineExtension("trigger-children", {
|
||||||
xhr: XMLHttpRequest,
|
xhr: XMLHttpRequest,
|
||||||
parameters: FormData,
|
parameters: FormData,
|
||||||
elt: Node,
|
elt: Node,
|
||||||
) {},
|
) {
|
||||||
|
},
|
||||||
getSelectors: function (): string[] | null {
|
getSelectors: function (): string[] | null {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type RequestContext struct {
|
type RequestContext struct {
|
||||||
*http.Request
|
Request *http.Request
|
||||||
|
Response http.ResponseWriter
|
||||||
locator *service.Locator
|
locator *service.Locator
|
||||||
isBoosted bool
|
isBoosted bool
|
||||||
currentBrowserUrl string
|
currentBrowserUrl string
|
||||||
|
|
@ -120,6 +121,7 @@ func (app *App) start() {
|
||||||
cc := &RequestContext{
|
cc := &RequestContext{
|
||||||
locator: app.Opts.ServiceLocator,
|
locator: app.Opts.ServiceLocator,
|
||||||
Request: r,
|
Request: r,
|
||||||
|
Response: w,
|
||||||
kv: make(map[string]interface{}),
|
kv: make(map[string]interface{}),
|
||||||
}
|
}
|
||||||
populateHxFields(cc)
|
populateHxFields(cc)
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,10 @@ func NewAttributeMap(pairs ...string) *AttributeMapOrdered {
|
||||||
return &AttributeMapOrdered{data: m}
|
return &AttributeMapOrdered{data: m}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NoSwap() *AttributeR {
|
||||||
|
return Attribute("hx-swap", "none")
|
||||||
|
}
|
||||||
|
|
||||||
func Attribute(key string, value string) *AttributeR {
|
func Attribute(key string, value string) *AttributeR {
|
||||||
return &AttributeR{
|
return &AttributeR{
|
||||||
Name: key,
|
Name: key,
|
||||||
|
|
@ -116,7 +120,7 @@ func HxIndicator(tag string) *AttributeR {
|
||||||
return Attribute(hx.IndicatorAttr, tag)
|
return Attribute(hx.IndicatorAttr, tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TriggerChildren() Ren {
|
func TriggerChildren() *AttributeR {
|
||||||
return HxExtension("trigger-children")
|
return HxExtension("trigger-children")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,10 +137,26 @@ func HxTriggerClick(opts ...hx.Modifier) *AttributeR {
|
||||||
return HxTrigger(hx.OnClick(opts...))
|
return HxTrigger(hx.OnClick(opts...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func HxExtension(value string) Ren {
|
func HxExtension(value string) *AttributeR {
|
||||||
return Attribute(hx.ExtAttr, value)
|
return Attribute(hx.ExtAttr, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HxExtensions(value ...string) Ren {
|
||||||
|
return Attribute(hx.ExtAttr, strings.Join(value, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
func JoinExtensions(attrs ...*AttributeR) Ren {
|
||||||
|
return JoinAttributes(", ", attrs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func JoinAttributes(sep string, attrs ...*AttributeR) *AttributeR {
|
||||||
|
values := make([]string, 0, len(attrs))
|
||||||
|
for _, a := range attrs {
|
||||||
|
values = append(values, a.Value)
|
||||||
|
}
|
||||||
|
return Attribute(attrs[0].Name, strings.Join(values, sep))
|
||||||
|
}
|
||||||
|
|
||||||
func Href(path string) Ren {
|
func Href(path string) Ren {
|
||||||
return Attribute("href", path)
|
return Attribute("href", path)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,11 +51,27 @@ func SwapManyPartialWithHeaders(ctx *RequestContext, headers *Headers, swaps ...
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RedirectPartial(path string) *Partial {
|
||||||
|
return RedirectPartialWithHeaders(path, NewHeaders())
|
||||||
|
}
|
||||||
|
|
||||||
|
func RedirectPartialWithHeaders(path string, headers *Headers) *Partial {
|
||||||
|
h := *NewHeaders("HX-Redirect", path)
|
||||||
|
for k, v := range *headers {
|
||||||
|
h[k] = v
|
||||||
|
}
|
||||||
|
return NewPartialWithHeaders(&h, Fragment())
|
||||||
|
}
|
||||||
|
|
||||||
func SwapPartial(ctx *RequestContext, swap *Element) *Partial {
|
func SwapPartial(ctx *RequestContext, swap *Element) *Partial {
|
||||||
return NewPartial(
|
return NewPartial(
|
||||||
SwapMany(ctx, swap))
|
SwapMany(ctx, swap))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func EmptyPartial() *Partial {
|
||||||
|
return NewPartial(Fragment())
|
||||||
|
}
|
||||||
|
|
||||||
func SwapManyPartial(ctx *RequestContext, swaps ...*Element) *Partial {
|
func SwapManyPartial(ctx *RequestContext, swaps ...*Element) *Partial {
|
||||||
return NewPartial(
|
return NewPartial(
|
||||||
SwapMany(ctx, swaps...),
|
SwapMany(ctx, swaps...),
|
||||||
|
|
|
||||||
|
|
@ -32,17 +32,20 @@ func renderJs(t *testing.T, command Command) string {
|
||||||
value := parsed.FirstChild.FirstChild.NextSibling.LastChild.Attr[0].Val
|
value := parsed.FirstChild.FirstChild.NextSibling.LastChild.Attr[0].Val
|
||||||
isComplex := strings.HasPrefix(value, "__eval_")
|
isComplex := strings.HasPrefix(value, "__eval_")
|
||||||
if !isComplex {
|
if !isComplex {
|
||||||
return value
|
value = strings.ReplaceAll(value, "var e=event;", "")
|
||||||
|
return strings.ReplaceAll(value, "var self=this;", "")
|
||||||
} else {
|
} else {
|
||||||
id := strings.TrimSuffix(value, "(this);")
|
id := strings.TrimSuffix(value, "(this, event);")
|
||||||
script := findScriptById(parsed, id)
|
script := findScriptById(parsed, id)
|
||||||
assert.NotNil(t, script)
|
assert.NotNil(t, script)
|
||||||
funcCall := script.LastChild.Data
|
funcCall := script.LastChild.Data
|
||||||
funcCall = strings.ReplaceAll(funcCall, "\n", "")
|
funcCall = strings.ReplaceAll(funcCall, "\n", "")
|
||||||
funcCall = strings.ReplaceAll(funcCall, "\t", "")
|
funcCall = strings.ReplaceAll(funcCall, "\t", "")
|
||||||
start := fmt.Sprintf("function %s(self) {", id)
|
start := fmt.Sprintf("function %s(self, event) {", id)
|
||||||
funcCall = strings.TrimPrefix(funcCall, start)
|
funcCall = strings.TrimPrefix(funcCall, start)
|
||||||
funcCall = strings.TrimSuffix(funcCall, "}")
|
funcCall = strings.TrimSuffix(funcCall, "}")
|
||||||
|
funcCall = strings.ReplaceAll(funcCall, "let e = event;", "")
|
||||||
|
funcCall = strings.ReplaceAll(funcCall, "var self=this;", "")
|
||||||
return funcCall
|
return funcCall
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package h
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
func BaseExtensions() string {
|
func BaseExtensions() string {
|
||||||
extensions := []string{"path-deps", "response-targets", "mutation-error", "htmgo"}
|
extensions := []string{"path-deps", "response-targets", "mutation-error", "htmgo", "sse"}
|
||||||
if IsDevelopment() {
|
if IsDevelopment() {
|
||||||
extensions = append(extensions, "livereload")
|
extensions = append(extensions, "livereload")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ func CombineHeaders(headers ...*Headers) *Headers {
|
||||||
}
|
}
|
||||||
|
|
||||||
func CurrentPath(ctx *RequestContext) string {
|
func CurrentPath(ctx *RequestContext) string {
|
||||||
current := ctx.Header.Get(hx.CurrentUrlHeader)
|
current := ctx.Request.Header.Get(hx.CurrentUrlHeader)
|
||||||
parsed, err := url.Parse(current)
|
parsed, err := url.Parse(current)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,34 @@ func OnEvent(event hx.Event, cmd ...Command) *LifeCycle {
|
||||||
return NewLifeCycle().OnEvent(event, cmd...)
|
return NewLifeCycle().OnEvent(event, cmd...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HxBeforeSseMessage(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().OnEvent(hx.SseBeforeMessageEvent, cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HxAfterSseMessage(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().OnEvent(hx.SseAfterMessageEvent, cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func OnSubmit(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().OnEvent(hx.SubmitEvent, cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HxOnSseError(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().OnEvent(hx.SseErrorEvent, cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HxOnSseClose(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().OnEvent(hx.SseClosedEvent, cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HxOnSseConnecting(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().OnEvent(hx.SseConnectingEvent, cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HxOnSseOpen(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().OnEvent(hx.SseConnectedEvent, cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
func HxBeforeRequest(cmd ...Command) *LifeCycle {
|
func HxBeforeRequest(cmd ...Command) *LifeCycle {
|
||||||
return NewLifeCycle().HxBeforeRequest(cmd...)
|
return NewLifeCycle().HxBeforeRequest(cmd...)
|
||||||
}
|
}
|
||||||
|
|
@ -261,12 +289,25 @@ func EvalJs(js string) ComplexJsCommand {
|
||||||
return NewComplexJsCommand(js)
|
return NewComplexJsCommand(js)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PreventDefault() SimpleJsCommand {
|
||||||
|
// language=JavaScript
|
||||||
|
return SimpleJsCommand{Command: "event.preventDefault()"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConsoleLog(text string) SimpleJsCommand {
|
||||||
|
// language=JavaScript
|
||||||
|
return SimpleJsCommand{Command: fmt.Sprintf("console.log('%s')", text)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetValue(value string) SimpleJsCommand {
|
||||||
|
// language=JavaScript
|
||||||
|
return SimpleJsCommand{Command: fmt.Sprintf("this.value = '%s'", value)}
|
||||||
|
}
|
||||||
|
|
||||||
func SubmitFormOnEnter() ComplexJsCommand {
|
func SubmitFormOnEnter() ComplexJsCommand {
|
||||||
// language=JavaScript
|
// language=JavaScript
|
||||||
return EvalJs(`
|
return EvalJs(`
|
||||||
if (event.code === 'Enter') {
|
if (event.code === 'Enter') { self.form.dispatchEvent(new Event('submit', { cancelable: true })); }
|
||||||
self.form.dispatchEvent(new Event('submit', { cancelable: true }));
|
|
||||||
}
|
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ func (q *Qs) ToString() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetQueryParam(ctx *RequestContext, key string) string {
|
func GetQueryParam(ctx *RequestContext, key string) string {
|
||||||
value, ok := ctx.URL.Query()[key]
|
value, ok := ctx.Request.URL.Query()[key]
|
||||||
if value == nil || !ok {
|
if value == nil || !ok {
|
||||||
current := ctx.currentBrowserUrl
|
current := ctx.currentBrowserUrl
|
||||||
if current != "" {
|
if current != "" {
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ func TestRender(t *testing.T) {
|
||||||
Attribute("data-attr-2", "value"),
|
Attribute("data-attr-2", "value"),
|
||||||
Attributes(&AttributeMap{
|
Attributes(&AttributeMap{
|
||||||
"data-attr-3": "value",
|
"data-attr-3": "value",
|
||||||
"data-attr-4": "value",
|
|
||||||
}),
|
}),
|
||||||
HxBeforeRequest(
|
HxBeforeRequest(
|
||||||
SetText("before request"),
|
SetText("before request"),
|
||||||
|
|
@ -41,12 +40,12 @@ func TestRender(t *testing.T) {
|
||||||
|
|
||||||
div.attributes.Set("data-attr-1", "value")
|
div.attributes.Set("data-attr-1", "value")
|
||||||
|
|
||||||
expected := `<div data-attr-1="value" id="my-div" data-attr-2="value" data-attr-3="value" data-attr-4="value" hx-on::before-request="this.innerText = 'before request';" hx-on::after-request="this.innerText = 'after request';"><div>hello, world</div>hello, child</div>`
|
expected := `<div data-attr-1="value" id="my-div" data-attr-2="value" data-attr-3="value" hx-on::before-request="this.innerText = 'before request';" hx-on::after-request="this.innerText = 'after request';"><div>hello, world</div>hello, child</div>`
|
||||||
result := Render(div)
|
result := Render(div)
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
expected,
|
expected,
|
||||||
result)
|
strings.ReplaceAll(result, "var self=this;var e=event;", ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderAttributes_1(t *testing.T) {
|
func TestRenderAttributes_1(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,8 @@ type RenderContext struct {
|
||||||
func (ctx *RenderContext) AddScript(funcName string, body string) {
|
func (ctx *RenderContext) AddScript(funcName string, body string) {
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
<script id="%s">
|
<script id="%s">
|
||||||
function %s(self) {
|
function %s(self, event) {
|
||||||
|
let e = event;
|
||||||
%s
|
%s
|
||||||
}
|
}
|
||||||
</script>`, funcName, funcName, body)
|
</script>`, funcName, funcName, body)
|
||||||
|
|
@ -51,7 +52,9 @@ func (ctx *RenderContext) AddScript(funcName string, body string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (node *Element) Render(context *RenderContext) {
|
func (node *Element) Render(context *RenderContext) {
|
||||||
// some elements may not have a tag, such as a Fragment
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if node.tag == CachedNodeTag {
|
if node.tag == CachedNodeTag {
|
||||||
meta := node.meta.(*CachedNode)
|
meta := node.meta.(*CachedNode)
|
||||||
|
|
@ -65,6 +68,7 @@ func (node *Element) Render(context *RenderContext) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// some elements may not have a tag, such as a Fragment
|
||||||
if node.tag != "" {
|
if node.tag != "" {
|
||||||
context.builder.WriteString("<")
|
context.builder.WriteString("<")
|
||||||
context.builder.WriteString(node.tag)
|
context.builder.WriteString(node.tag)
|
||||||
|
|
@ -220,10 +224,10 @@ func (l *LifeCycle) Render(context *RenderContext) {
|
||||||
for _, command := range commands {
|
for _, command := range commands {
|
||||||
switch c := command.(type) {
|
switch c := command.(type) {
|
||||||
case SimpleJsCommand:
|
case SimpleJsCommand:
|
||||||
m[event] += fmt.Sprintf("%s;", c.Command)
|
m[event] += fmt.Sprintf("var self=this;var e=event;%s;", c.Command)
|
||||||
case ComplexJsCommand:
|
case ComplexJsCommand:
|
||||||
context.AddScript(c.TempFuncName, c.Command)
|
context.AddScript(c.TempFuncName, c.Command)
|
||||||
m[event] += fmt.Sprintf("%s(this);", c.TempFuncName)
|
m[event] += fmt.Sprintf("%s(this, event);", c.TempFuncName)
|
||||||
case *AttributeMapOrdered:
|
case *AttributeMapOrdered:
|
||||||
c.Each(func(key string, value string) {
|
c.Each(func(key string, value string) {
|
||||||
l.fromAttributeMap(event, key, value, context)
|
l.fromAttributeMap(event, key, value, context)
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ func SwapMany(ctx *RequestContext, elements ...*Element) *Element {
|
||||||
for _, element := range elements {
|
for _, element := range elements {
|
||||||
element.AppendChild(outOfBandSwap(""))
|
element.AppendChild(outOfBandSwap(""))
|
||||||
}
|
}
|
||||||
return Template(Map(elements, func(arg *Element) Ren {
|
return Fragment(Map(elements, func(arg *Element) Ren {
|
||||||
return arg
|
return arg
|
||||||
})...)
|
})...)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package h
|
package h
|
||||||
|
|
||||||
import "github.com/maddalax/htmgo/framework/hx"
|
import (
|
||||||
|
"github.com/maddalax/htmgo/framework/hx"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
func Get(path string, trigger ...string) *AttributeMapOrdered {
|
func Get(path string, trigger ...string) *AttributeMapOrdered {
|
||||||
return AttributeList(Attribute(hx.GetAttr, path), HxTriggerString(trigger...))
|
return AttributeList(Attribute(hx.GetAttr, path), HxTriggerString(trigger...))
|
||||||
|
|
@ -19,10 +22,18 @@ func GetWithQs(path string, qs *Qs, trigger string) *AttributeMapOrdered {
|
||||||
}
|
}
|
||||||
|
|
||||||
func PostPartial(partial PartialFunc, triggers ...string) *AttributeMapOrdered {
|
func PostPartial(partial PartialFunc, triggers ...string) *AttributeMapOrdered {
|
||||||
return Post(GetPartialPath(partial), triggers...)
|
path := GetPartialPath(partial)
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
return Post(path, triggers...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func PostPartialWithQs(partial PartialFunc, qs *Qs, trigger ...string) *AttributeMapOrdered {
|
func PostPartialWithQs(partial PartialFunc, qs *Qs, trigger ...string) *AttributeMapOrdered {
|
||||||
|
path := GetPartialPathWithQs(partial, qs)
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
return Post(GetPartialPathWithQs(partial, qs), trigger...)
|
return Post(GetPartialPathWithQs(partial, qs), trigger...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,12 @@ const (
|
||||||
XhrLoadEndEvent Event = "htmx:xhr:loadend"
|
XhrLoadEndEvent Event = "htmx:xhr:loadend"
|
||||||
XhrLoadStartEvent Event = "htmx:xhr:loadstart"
|
XhrLoadStartEvent Event = "htmx:xhr:loadstart"
|
||||||
XhrProgressEvent Event = "htmx:xhr:progress"
|
XhrProgressEvent Event = "htmx:xhr:progress"
|
||||||
|
SseConnectedEvent Event = "htmx:sseOpen"
|
||||||
|
SseConnectingEvent Event = "htmx:sseConnecting"
|
||||||
|
SseClosedEvent Event = "htmx:sseClose"
|
||||||
|
SseErrorEvent Event = "htmx:sseError"
|
||||||
|
SseBeforeMessageEvent Event = "htmx:sseBeforeMessage"
|
||||||
|
SseAfterMessageEvent Event = "htmx:sseAfterMessage"
|
||||||
|
|
||||||
// RevealedEvent Misc Events
|
// RevealedEvent Misc Events
|
||||||
RevealedEvent Event = "revealed"
|
RevealedEvent Event = "revealed"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ var EvalJsOnParent = h.EvalJsOnParent
|
||||||
var SetClassOnSibling = h.SetClassOnSibling
|
var SetClassOnSibling = h.SetClassOnSibling
|
||||||
var RemoveClassOnSibling = h.RemoveClassOnSibling
|
var RemoveClassOnSibling = h.RemoveClassOnSibling
|
||||||
var Remove = h.Remove
|
var Remove = h.Remove
|
||||||
|
var PreventDefault = h.PreventDefault
|
||||||
var EvalJs = h.EvalJs
|
var EvalJs = h.EvalJs
|
||||||
|
var ConsoleLog = h.ConsoleLog
|
||||||
|
var SetValue = h.SetValue
|
||||||
var SubmitFormOnEnter = h.SubmitFormOnEnter
|
var SubmitFormOnEnter = h.SubmitFormOnEnter
|
||||||
var InjectScript = h.InjectScript
|
var InjectScript = h.InjectScript
|
||||||
var InjectScriptIfNotExist = h.InjectScriptIfNotExist
|
var InjectScriptIfNotExist = h.InjectScriptIfNotExist
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ OnClick(cmd ...Command) *LifeCycle
|
||||||
HxOnAfterSwap(cmd ...Command) *LifeCycle
|
HxOnAfterSwap(cmd ...Command) *LifeCycle
|
||||||
HxOnLoad(cmd ...Command) *LifeCycle
|
HxOnLoad(cmd ...Command) *LifeCycle
|
||||||
```
|
```
|
||||||
|
**Note:** Each command you attach to the event handler will be passed 'self' and 'event' (if applicable) as arguments.
|
||||||
|
'self' is the current element, and 'event' is the event object.
|
||||||
|
|
||||||
If you use the OnEvent directly, event names may be any [HTML DOM](https://www.w3schools.com/jsref/dom_obj_event.asp) events, or any [HTMX events](https://htmx.org/events/).
|
If you use the OnEvent directly, event names may be any [HTML DOM](https://www.w3schools.com/jsref/dom_obj_event.asp) events, or any [HTMX events](https://htmx.org/events/).
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue