hn clone
This commit is contained in:
parent
c0fabcedd2
commit
4880946515
33 changed files with 920 additions and 11 deletions
11
examples/hackernews/.dockerignore
Normal file
11
examples/hackernews/.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/hackernews/.gitignore
vendored
Normal file
6
examples/hackernews/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/assets/dist
|
||||
tmp
|
||||
node_modules
|
||||
.idea
|
||||
__htmgo
|
||||
dist
|
||||
38
examples/hackernews/Dockerfile
Normal file
38
examples/hackernews/Dockerfile
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Stage 1: Build the Go binary
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
RUN apk update
|
||||
RUN apk add git
|
||||
RUN apk add curl
|
||||
|
||||
# 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 GOPRIVATE=github.com/maddalax GOPROXY=direct go run github.com/maddalax/htmgo/cli/htmgo@latest build
|
||||
|
||||
|
||||
# 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 ["./hackernews"]
|
||||
20
examples/hackernews/Taskfile.yml
Normal file
20
examples/hackernews/Taskfile.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
version: '3'
|
||||
|
||||
tasks:
|
||||
run:
|
||||
cmds:
|
||||
- htmgo run
|
||||
silent: true
|
||||
|
||||
build:
|
||||
cmds:
|
||||
- htmgo build
|
||||
|
||||
docker:
|
||||
cmds:
|
||||
- docker build .
|
||||
|
||||
watch:
|
||||
cmds:
|
||||
- htmgo watch
|
||||
silent: true
|
||||
13
examples/hackernews/assets.go
Normal file
13
examples/hackernews/assets.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//go:build !prod
|
||||
// +build !prod
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"hackernews/internal/embedded"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
func GetStaticAssets() fs.FS {
|
||||
return embedded.NewOsFs()
|
||||
}
|
||||
15
examples/hackernews/assets/css/input.css
Normal file
15
examples/hackernews/assets/css/input.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
/* Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
}
|
||||
BIN
examples/hackernews/assets/public/apple-touch-icon.png
Normal file
BIN
examples/hackernews/assets/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
examples/hackernews/assets/public/favicon.ico
Normal file
BIN
examples/hackernews/assets/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
BIN
examples/hackernews/assets/public/icon-192-maskable.png
Normal file
BIN
examples/hackernews/assets/public/icon-192-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
examples/hackernews/assets/public/icon-192.png
Normal file
BIN
examples/hackernews/assets/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
BIN
examples/hackernews/assets/public/icon-512-maskable.png
Normal file
BIN
examples/hackernews/assets/public/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
examples/hackernews/assets/public/icon-512.png
Normal file
BIN
examples/hackernews/assets/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
16
examples/hackernews/assets_prod.go
Normal file
16
examples/hackernews/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
|
||||
}
|
||||
14
examples/hackernews/components/badge.go
Normal file
14
examples/hackernews/components/badge.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package components
|
||||
|
||||
import "github.com/maddalax/htmgo/framework/h"
|
||||
|
||||
func Badge(text string, active bool, children ...h.Ren) *h.Element {
|
||||
return h.Button(
|
||||
h.Text(text),
|
||||
h.ClassX("font-semibold px-3 py-1 rounded-full cursor-pointer h-[32px]", h.ClassMap{
|
||||
"bg-rose-500 text-white": active,
|
||||
"bg-neutral-300": !active,
|
||||
}),
|
||||
h.Children(children...),
|
||||
)
|
||||
}
|
||||
10
examples/hackernews/go.mod
Normal file
10
examples/hackernews/go.mod
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
module hackernews
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/maddalax/htmgo/framework v0.0.0-20241009153712-95f9b43395b7
|
||||
)
|
||||
|
||||
require github.com/google/uuid v1.6.0 // indirect
|
||||
18
examples/hackernews/go.sum
Normal file
18
examples/hackernews/go.sum
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
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-20241009153712-95f9b43395b7 h1:KzbU4UIVDc+ppklnwEEAAGLXpED16hUoGbbx1qhN7eo=
|
||||
github.com/maddalax/htmgo/framework v0.0.0-20241009153712-95f9b43395b7/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
|
||||
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=
|
||||
30
examples/hackernews/internal/batch/parallel.go
Normal file
30
examples/hackernews/internal/batch/parallel.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package batch
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
func ParallelProcess[T any, Z any](items []T, concurrency int, cb func(item T) Z) []Z {
|
||||
if len(items) == 0 {
|
||||
return []Z{}
|
||||
}
|
||||
if len(items) == 1 {
|
||||
return []Z{cb(items[0])}
|
||||
}
|
||||
results := make([]Z, len(items))
|
||||
wg := sync.WaitGroup{}
|
||||
sem := make(chan struct{}, concurrency)
|
||||
for i, item := range items {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(item T) {
|
||||
defer func() {
|
||||
wg.Done()
|
||||
<-sem
|
||||
}()
|
||||
results[i] = cb(item)
|
||||
}(item)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
17
examples/hackernews/internal/embedded/os.go
Normal file
17
examples/hackernews/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{}
|
||||
}
|
||||
115
examples/hackernews/internal/httpjson/client.go
Normal file
115
examples/hackernews/internal/httpjson/client.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package httpjson
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
client *http.Client
|
||||
once sync.Once // Consider allowing configuration parameters for the singleton
|
||||
)
|
||||
|
||||
func getClient() *http.Client {
|
||||
once.Do(func() {
|
||||
client = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
})
|
||||
return client
|
||||
}
|
||||
|
||||
func Get[T any](url string) (*T, error) {
|
||||
client := getClient()
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result T
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func Post[T any](url string, data T) (*http.Response, error) {
|
||||
client := getClient()
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Post(url, "application/json", bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
return resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func Patch[T any](url string, data T) error {
|
||||
client := getClient()
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPatch, url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Delete(url string) error {
|
||||
client := getClient()
|
||||
req, err := http.NewRequest(http.MethodDelete, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
137
examples/hackernews/internal/news/news.go
Normal file
137
examples/hackernews/internal/news/news.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
package news
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"hackernews/internal/batch"
|
||||
"hackernews/internal/httpjson"
|
||||
"hackernews/internal/timeformat"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const baseUrl = "https://hacker-news.firebaseio.com/v0/"
|
||||
|
||||
func url(path string, qs *h.Qs) string {
|
||||
return baseUrl + path + ".json?" + qs.ToString()
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
var Categories = []Category{
|
||||
{"Top Stories", "topstories"},
|
||||
{"Best Stories", "beststories"},
|
||||
{"New Stories", "newstories"},
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
By string `json:"by"`
|
||||
Text string `json:"text"`
|
||||
TimeRaw int64 `json:"time"`
|
||||
Time time.Time `json:"-"`
|
||||
Type string `json:"type"`
|
||||
Kids []int `json:"kids"`
|
||||
Parent int `json:"parent"`
|
||||
Id int `json:"id"`
|
||||
}
|
||||
|
||||
type Story struct {
|
||||
Id int `json:"id"`
|
||||
By string `json:"by"`
|
||||
Text string `json:"text"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Descendents int `json:"descendants"`
|
||||
Score int `json:"score"`
|
||||
Url string
|
||||
TimeRaw int64 `json:"time"`
|
||||
Time time.Time `json:"-"`
|
||||
// comment ids
|
||||
Kids []int
|
||||
}
|
||||
|
||||
type GetTopStoriesRequest struct {
|
||||
Limit int
|
||||
Page int
|
||||
}
|
||||
|
||||
func MustItemId(ctx *h.RequestContext) int {
|
||||
raw := h.GetQueryParam(ctx, "item")
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(parsed)
|
||||
}
|
||||
|
||||
func GetStories(category string, page int, limit int) []Story {
|
||||
top, err := httpjson.Get[[]int](url(category, h.NewQs()))
|
||||
if err != nil {
|
||||
slog.Error("failed to load top stories", slog.String("err", err.Error()))
|
||||
return make([]Story, 0)
|
||||
}
|
||||
ids := *top
|
||||
start := page * limit
|
||||
end := start + limit
|
||||
return batch.ParallelProcess[int, Story](
|
||||
ids[start:end],
|
||||
50,
|
||||
func(id int) Story {
|
||||
story, err := GetStory(id)
|
||||
if err != nil {
|
||||
slog.Error("failed to load story", slog.Int("id", id), slog.String("err", err.Error()))
|
||||
return Story{}
|
||||
}
|
||||
return *story
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func GetTopStories(page int, limit int) []Story {
|
||||
return GetStories("topstories", page, limit)
|
||||
}
|
||||
|
||||
func GetBestStories(page int, limit int) []Story {
|
||||
return GetStories("beststories", page, limit)
|
||||
}
|
||||
|
||||
func GetNewStories(page int, limit int) []Story {
|
||||
return GetStories("newstories", page, limit)
|
||||
}
|
||||
|
||||
func GetComments(ids []int) []Comment {
|
||||
return batch.ParallelProcess(
|
||||
ids,
|
||||
50,
|
||||
func(id int) Comment {
|
||||
comment, err := GetComment(id)
|
||||
if err != nil {
|
||||
slog.Error("failed to load comment", slog.Int("id", id), slog.String("err", err.Error()))
|
||||
return Comment{}
|
||||
}
|
||||
return *comment
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func GetComment(id int) (*Comment, error) {
|
||||
c, err := httpjson.Get[Comment](url(fmt.Sprintf("item/%d", id), h.NewQs()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Time = timeformat.ParseUnix(c.TimeRaw)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func GetStory(id int) (*Story, error) {
|
||||
s, err := httpjson.Get[Story](url(fmt.Sprintf("item/%d", id), h.NewQs()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Time = timeformat.ParseUnix(s.TimeRaw)
|
||||
return s, nil
|
||||
}
|
||||
13
examples/hackernews/internal/random.go
Normal file
13
examples/hackernews/internal/random.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package internal
|
||||
|
||||
import "math/rand"
|
||||
|
||||
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
|
||||
func RandSeq(n int) string {
|
||||
b := make([]rune, n)
|
||||
for i := range b {
|
||||
b[i] = letters[rand.Intn(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
39
examples/hackernews/internal/timeformat/time.go
Normal file
39
examples/hackernews/internal/timeformat/time.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package timeformat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ParseUnix(t int64) time.Time {
|
||||
return time.UnixMilli(t * 1000)
|
||||
}
|
||||
|
||||
func RelativeTime(t time.Time) string {
|
||||
now := time.Now()
|
||||
diff := now.Sub(t)
|
||||
|
||||
var pluralize = func(s string) string {
|
||||
if s[0] == '1' {
|
||||
return s[:len(s)-5] + " ago"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
switch {
|
||||
case diff < time.Minute:
|
||||
return "just now"
|
||||
case diff < time.Hour:
|
||||
return pluralize(fmt.Sprintf("%d minutes ago", int(diff.Minutes())))
|
||||
case diff < time.Hour*24:
|
||||
return pluralize(fmt.Sprintf("%d hours ago", int(diff.Hours())))
|
||||
case diff < time.Hour*24*7:
|
||||
return pluralize(fmt.Sprintf("%d days ago", int(diff.Hours()/24)))
|
||||
case diff < time.Hour*24*30:
|
||||
return pluralize(fmt.Sprintf("%d weeks ago", int(diff.Hours()/(24*7))))
|
||||
case diff < time.Hour*24*365:
|
||||
return pluralize(fmt.Sprintf("%d months ago", int(diff.Hours()/(24*30))))
|
||||
default:
|
||||
return pluralize(fmt.Sprintf("%d years ago", int(diff.Hours()/(24*365))))
|
||||
}
|
||||
}
|
||||
30
examples/hackernews/main.go
Normal file
30
examples/hackernews/main.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"github.com/maddalax/htmgo/framework/service"
|
||||
"hackernews/__htmgo"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
locator := service.NewLocator()
|
||||
|
||||
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)))
|
||||
__htmgo.Register(app.Router)
|
||||
},
|
||||
})
|
||||
}
|
||||
21
examples/hackernews/pages/index.go
Normal file
21
examples/hackernews/pages/index.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"hackernews/partials"
|
||||
)
|
||||
|
||||
func IndexPage(ctx *h.RequestContext) *h.Page {
|
||||
return h.NewPage(
|
||||
RootPage(
|
||||
h.Div(
|
||||
h.Class("flex gap-2 min-h-screen"),
|
||||
partials.StorySidebar(ctx),
|
||||
h.Main(
|
||||
h.Class("flex justify-center items-start p-6 max-w-3xl min-w-3xl mx-auto"),
|
||||
partials.Story(ctx),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
32
examples/hackernews/pages/root.go
Normal file
32
examples/hackernews/pages/root.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
)
|
||||
|
||||
func RootPage(children ...h.Ren) h.Ren {
|
||||
return h.Html(
|
||||
h.HxExtensions(h.BaseExtensions()),
|
||||
h.Head(
|
||||
h.Meta("viewport", "width=device-width, initial-scale=1"),
|
||||
h.Link("/public/favicon.ico", "icon"),
|
||||
h.Link("/public/apple-touch-icon.png", "apple-touch-icon"),
|
||||
h.Meta("title", "htmgo template"),
|
||||
h.Meta("charset", "utf-8"),
|
||||
h.Meta("author", "htmgo"),
|
||||
h.Meta("description", "this is a template"),
|
||||
h.Meta("og:title", "htmgo template"),
|
||||
h.Meta("og:url", "https://htmgo.dev"),
|
||||
h.Link("canonical", "https://htmgo.dev"),
|
||||
h.Meta("og:description", "this is a template"),
|
||||
h.Link("/public/main.css", "stylesheet"),
|
||||
h.Script("/public/htmgo.js"),
|
||||
h.Script("/public/custom.js"),
|
||||
),
|
||||
h.Body(
|
||||
h.Div(
|
||||
h.Fragment(children...),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
84
examples/hackernews/partials/comments.go
Normal file
84
examples/hackernews/partials/comments.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package partials
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"hackernews/internal/batch"
|
||||
"hackernews/internal/news"
|
||||
"hackernews/internal/timeformat"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func StoryComments(ctx *h.RequestContext) *h.Partial {
|
||||
return h.NewPartial(
|
||||
h.Div(
|
||||
h.Class("flex flex-col gap-3 prose max-w-none"),
|
||||
CachedStoryComments(news.MustItemId(ctx)),
|
||||
))
|
||||
}
|
||||
|
||||
var CachedStoryComments = h.CachedPerKeyT[string, int](time.Minute*3, func(itemId int) (string, h.GetElementFunc) {
|
||||
return fmt.Sprintf("story-comments-%d", itemId), func() *h.Element {
|
||||
story, err := news.GetStory(itemId)
|
||||
|
||||
if err != nil {
|
||||
return h.Div(
|
||||
h.Text("Failed to load story"),
|
||||
)
|
||||
}
|
||||
|
||||
comments := news.GetComments(story.Kids)
|
||||
|
||||
// parallel process because each comment needs to load its children comments
|
||||
items := batch.ParallelProcess[news.Comment, *h.Element](comments, 50, func(item news.Comment) *h.Element {
|
||||
return Comment(item, 0)
|
||||
})
|
||||
|
||||
return h.List(items, func(item *h.Element, index int) *h.Element {
|
||||
return item
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
func Comment(item news.Comment, nesting int) *h.Element {
|
||||
if item.Text == "" {
|
||||
return h.Empty()
|
||||
}
|
||||
|
||||
children := news.GetComments(item.Kids)
|
||||
|
||||
return h.Div(
|
||||
h.ClassX("block bg-white pb-2 pt-2", h.ClassMap{
|
||||
"border-b border-gray-200": nesting == 0,
|
||||
"border-l border-gray-200": nesting > 0,
|
||||
}),
|
||||
h.If(nesting > 0, h.Attribute("style", fmt.Sprintf("margin-left: %dpx", (nesting-1)*15))),
|
||||
h.Div(
|
||||
h.If(nesting > 0, h.Class("pl-4")),
|
||||
h.Div(
|
||||
h.Class("flex gap-1 items-center"),
|
||||
h.Div(
|
||||
h.Class("font-bold text-rose-500"),
|
||||
h.UnsafeRaw(item.By),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("text-sm text-gray-600"),
|
||||
h.UnsafeRaw("•"),
|
||||
h.TextF(" %s", timeformat.RelativeTime(item.Time)),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("text-sm text-gray-600"),
|
||||
h.UnsafeRaw(strings.TrimSpace(item.Text)),
|
||||
),
|
||||
),
|
||||
h.If(len(children) > 0, h.List(
|
||||
children, func(child news.Comment, index int) *h.Element {
|
||||
return h.Div(
|
||||
Comment(child, nesting+1),
|
||||
)
|
||||
},
|
||||
)),
|
||||
)
|
||||
}
|
||||
126
examples/hackernews/partials/sidebar.go
Normal file
126
examples/hackernews/partials/sidebar.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
package partials
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"github.com/maddalax/htmgo/framework/hx"
|
||||
"hackernews/components"
|
||||
"hackernews/internal/news"
|
||||
"hackernews/internal/timeformat"
|
||||
"time"
|
||||
)
|
||||
|
||||
// @lang js
|
||||
var ScrollJs = `
|
||||
const scrollContainer = self;
|
||||
let isDown = false;
|
||||
let startX;
|
||||
let scrollLeft;
|
||||
|
||||
scrollContainer.addEventListener("mousedown", (e) => {
|
||||
isDown = true;
|
||||
scrollContainer.classList.add("active");
|
||||
startX = e.pageX - scrollContainer.offsetLeft;
|
||||
scrollLeft = scrollContainer.scrollLeft;
|
||||
});
|
||||
|
||||
scrollContainer.addEventListener("mouseleave", () => {
|
||||
isDown = false;
|
||||
scrollContainer.classList.remove("active");
|
||||
});
|
||||
|
||||
scrollContainer.addEventListener("mouseup", () => {
|
||||
isDown = false;
|
||||
scrollContainer.classList.remove("active");
|
||||
});
|
||||
|
||||
scrollContainer.addEventListener("mousemove", (e) => {
|
||||
if (!isDown) return;
|
||||
e.preventDefault();
|
||||
const x = e.pageX - scrollContainer.offsetLeft;
|
||||
const walk = (x - startX) * 3; // Adjust scroll speed here
|
||||
scrollContainer.scrollLeft = scrollLeft - walk;
|
||||
});
|
||||
`
|
||||
|
||||
func StorySidebar(ctx *h.RequestContext) *h.Partial {
|
||||
category := h.GetQueryParam(ctx, "category")
|
||||
if category == "" {
|
||||
category = "topstories"
|
||||
}
|
||||
|
||||
body := h.Aside(
|
||||
h.Id("story-sidebar"),
|
||||
h.JoinExtensions(h.TriggerChildren()),
|
||||
h.Class("sticky top-0 h-screen p-1 bg-gray-100 overflow-y-auto max-w-80 min-w-80"),
|
||||
h.Div(
|
||||
h.Class("flex flex-col gap-1"),
|
||||
SidebarTitle(category),
|
||||
CachedStoryList(category, 0, 50),
|
||||
),
|
||||
)
|
||||
|
||||
if ctx.IsHxRequest() {
|
||||
return h.SwapManyPartial(ctx, body)
|
||||
}
|
||||
return h.NewPartial(body)
|
||||
}
|
||||
|
||||
func SidebarTitle(defaultCategory string) *h.Element {
|
||||
today := time.Now().Format("Mon, 02 Jan 2006")
|
||||
return h.Div(
|
||||
h.Class("flex flex-col px-2 pt-4 pb-2"),
|
||||
h.Div(
|
||||
h.Class("text-sm text-gray-600"),
|
||||
h.Text(today),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("font-bold text-xl"),
|
||||
h.Text("Hacker News"),
|
||||
),
|
||||
h.Div(
|
||||
h.OnEvent(hx.LoadDomEvent, h.EvalJs(ScrollJs)),
|
||||
h.OnEvent(hx.LoadEvent, h.EvalJs(ScrollJs)),
|
||||
h.Class("scroll-container mt-2 flex gap-1 no-scrollbar overflow-y-hidden whitespace-nowrap overflow-x-auto"),
|
||||
h.List(news.Categories, func(item news.Category, index int) *h.Element {
|
||||
return CategoryBadge(defaultCategory, item)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func CategoryBadge(defaultCategory string, category news.Category) *h.Element {
|
||||
selected := category.Path == defaultCategory
|
||||
return components.Badge(
|
||||
category.Name,
|
||||
selected,
|
||||
h.Attribute("hx-swap", "none"),
|
||||
h.If(!selected, h.PostPartialOnClickQs(StorySidebar, h.NewQs("category", category.Path))),
|
||||
)
|
||||
}
|
||||
|
||||
var CachedStoryList = h.CachedPerKeyT3(time.Minute*5, func(category string, page int, limit int) (string, h.GetElementFunc) {
|
||||
return fmt.Sprintf("%s-stories-%d-%d", category, page, limit), func() *h.Element {
|
||||
stories := news.GetStories(category, page, limit)
|
||||
return h.List(stories, func(item news.Story, index int) *h.Element {
|
||||
return h.Div(
|
||||
h.Attribute("hx-swap", "none"),
|
||||
h.PostPartialOnClickQs(Story, h.NewQs("item", fmt.Sprintf("%d", item.Id))),
|
||||
h.A(h.Href(item.Url)),
|
||||
h.Class("block p-2 bg-white rounded-md shadow cursor-pointer"),
|
||||
h.Div(
|
||||
h.Class("font-bold"),
|
||||
h.UnsafeRaw(item.Title),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("text-sm text-gray-600"),
|
||||
h.Div(h.TextF("%s ", item.By), h.UnsafeRaw("•"), h.TextF(" %s", timeformat.RelativeTime(item.Time))),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("text-sm text-gray-600"),
|
||||
h.UnsafeRaw(fmt.Sprintf("%d upvotes • %d comments", item.Score, item.Descendents)),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
79
examples/hackernews/partials/story.go
Normal file
79
examples/hackernews/partials/story.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package partials
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"hackernews/internal/news"
|
||||
"hackernews/internal/timeformat"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func Story(ctx *h.RequestContext) *h.Partial {
|
||||
storyId, err := strconv.ParseInt(ctx.QueryParam("item"), 10, 64)
|
||||
|
||||
if err != nil {
|
||||
return h.SwapManyPartial(
|
||||
ctx,
|
||||
h.Div(
|
||||
h.Text("Invalid story id"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
story, err := news.GetStory(int(storyId))
|
||||
|
||||
if err != nil {
|
||||
return h.SwapManyPartial(
|
||||
ctx,
|
||||
h.Div(
|
||||
h.Text("Failed to load story"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if ctx.IsHxRequest() {
|
||||
return h.SwapManyPartialWithHeaders(
|
||||
ctx,
|
||||
h.PushUrlHeader(fmt.Sprintf("/?item=%d", storyId)),
|
||||
StoryBody(story),
|
||||
)
|
||||
}
|
||||
|
||||
return h.NewPartial(
|
||||
StoryBody(story),
|
||||
)
|
||||
}
|
||||
|
||||
func StoryBody(story *news.Story) *h.Element {
|
||||
return h.Div(
|
||||
h.Id("story-body"),
|
||||
h.Div(
|
||||
h.Class("prose prose-2xl bg-white border-b border-gray-200 pb-3 max-w-3xl"),
|
||||
h.H5(
|
||||
h.Class("flex gap-2 items-center font-bold"),
|
||||
h.UnsafeRaw(story.Title),
|
||||
),
|
||||
h.A(
|
||||
h.Href(story.Url),
|
||||
h.Class("text-sm text-rose-400 no-underline"),
|
||||
h.Text(story.Url),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("text-sm text-gray-600"),
|
||||
h.UnsafeRaw(story.Text),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("text-sm text-gray-600 mt-2"),
|
||||
h.TextF("%d upvotes ", story.Score),
|
||||
h.UnsafeRaw("•"),
|
||||
h.TextF(" %s ", story.By),
|
||||
h.UnsafeRaw("•"),
|
||||
h.TextF(" %s", timeformat.RelativeTime(story.Time)),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("mt-2 max-w-3xl"),
|
||||
h.GetPartial(StoryComments, "load"),
|
||||
),
|
||||
)
|
||||
}
|
||||
7
examples/hackernews/tailwind.config.js
Normal file
7
examples/hackernews/tailwind.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["**/*.go"],
|
||||
plugins: [
|
||||
require('@tailwindcss/typography')
|
||||
],
|
||||
};
|
||||
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
|
|
@ -8,6 +8,22 @@ import "./htmxextensions/livereload"
|
|||
import "./htmxextensions/htmgo";
|
||||
import "./htmxextensions/sse"
|
||||
|
||||
/**
|
||||
* Browser doesn't support onload for all elements, so we need to manually trigger it
|
||||
* this is useful for locality of behavior
|
||||
*/
|
||||
window.onload = function() {
|
||||
const ignored = ['SCRIPT', 'LINK', 'STYLE', 'META', 'BASE', 'TITLE', 'HEAD', 'HTML', 'BODY'];
|
||||
for (let element of Array.from(document.querySelectorAll(`[onload]`))) {
|
||||
if(element != null && element instanceof HTMLElement) {
|
||||
if(ignored.includes(element.tagName)) {
|
||||
continue
|
||||
}
|
||||
element.onload!(new Event("load"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
window.htmx = htmx;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ 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'];
|
||||
const ignoredEvents = ['htmx:beforeProcessNode', 'htmx:afterProcessNode', 'htmx:configRequest', 'htmx:configResponse', 'htmx:responseError'];
|
||||
|
||||
function makeEvent(eventName: string, detail: any) {
|
||||
let evt
|
||||
|
|
@ -28,13 +28,15 @@ function triggerChildren(target: HTMLElement, name: string, event: CustomEvent,
|
|||
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,
|
||||
target: e,
|
||||
})
|
||||
newEvent.detail.meta = 'trigger-children'
|
||||
e.dispatchEvent(newEvent)
|
||||
triggered.add(e as HTMLElement);
|
||||
setTimeout(() => {
|
||||
const newEvent = makeEvent(eventName.replace("hx-on::", "htmx:"), {
|
||||
...event.detail,
|
||||
target: e,
|
||||
})
|
||||
newEvent.detail.meta = 'trigger-children'
|
||||
e.dispatchEvent(newEvent)
|
||||
triggered.add(e as HTMLElement);
|
||||
}, 1)
|
||||
}
|
||||
if (e.children) {
|
||||
triggerChildren(e as HTMLElement, name, event, triggered);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
|
||||
func RootPage(children ...h.Ren) h.Ren {
|
||||
return h.Html(
|
||||
h.HxExtension(h.BaseExtensions()),
|
||||
h.HxExtensions(h.BaseExtensions()),
|
||||
h.Head(
|
||||
h.Meta("viewport", "width=device-width, initial-scale=1"),
|
||||
h.Link("/public/favicon.ico", "icon"),
|
||||
|
|
|
|||
Loading…
Reference in a new issue