commit ee0fe62ca4f011b5e05365adec12964970e70e22
parent b3995b782c0e91799d48bae881f2b300f17083a4
Author: brookjeynes <me@brookjeynes.dev>
Date: Tue, 28 Apr 2026 21:13:40 +1000
feat(ui/views): add new song page
Signed-off-by: brookjeynes <me@brookjeynes.dev>
Diffstat:
16 files changed, 378 insertions(+), 131 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1,2 +1,3 @@
.env
*/**/*templ.go
+static/files
diff --git a/dev.sh b/dev.sh
@@ -4,6 +4,9 @@ go tool templ generate
go tool templ fmt ./internal/server/ui/
go fmt ./...
+find internal static -path 'static/files' -prune -o -name '*.js' -print | while read f; do minify "$f" -o "static/files/js/$(basename "$f")"; done
+find internal static -path 'static/files' -prune -o -name '*.css' -print | while read f; do minify "$f" -o "static/files/$(basename "$f")"; done
+
if [ -f .env ]; then
export $(cat .env | sed 's/#.*//g' | xargs)
fi
diff --git a/go.mod b/go.mod
@@ -3,14 +3,15 @@ module lyrics
go 1.23.2
require (
+ github.com/a-h/templ v0.3.1001
github.com/charmbracelet/log v1.0.0
github.com/go-chi/chi/v5 v5.2.5
+ github.com/gorilla/schema v1.4.1
github.com/sethvargo/go-envconfig v1.3.0
)
require (
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
- github.com/a-h/templ v0.3.1001 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
diff --git a/go.sum b/go.sum
@@ -34,6 +34,8 @@ github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkv
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
+github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -68,8 +70,6 @@ golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
diff --git a/internal/domain/genre.go b/internal/domain/genre.go
@@ -0,0 +1,45 @@
+package domain
+
+type Genre string
+
+const (
+ GenreRap Genre = "rap"
+ GenrePop Genre = "pop"
+ GenreRnB Genre = "r&b"
+ GenreRock Genre = "rock"
+ GenreCountry Genre = "country"
+ GenreElectronic Genre = "electronic"
+ GenreJazz Genre = "jazz"
+ GenreClassical Genre = "classical"
+ GenreNonMusic Genre = "non-music"
+ GenreIndie Genre = "indie"
+ GenreInstrumental Genre = "instrumental"
+)
+
+var Genres = []Genre{
+ GenreRap,
+ GenrePop,
+ GenreRnB,
+ GenreRock,
+ GenreCountry,
+ GenreElectronic,
+ GenreJazz,
+ GenreClassical,
+ GenreNonMusic,
+ GenreIndie,
+ GenreInstrumental,
+}
+
+var ValidGenres = map[Genre]struct{}{
+ GenreRap: {},
+ GenrePop: {},
+ GenreRnB: {},
+ GenreRock: {},
+ GenreCountry: {},
+ GenreElectronic: {},
+ GenreJazz: {},
+ GenreClassical: {},
+ GenreNonMusic: {},
+ GenreIndie: {},
+ GenreInstrumental: {},
+}
diff --git a/internal/domain/song.go b/internal/domain/song.go
@@ -1,11 +1,18 @@
package domain
-import "time"
+import (
+ "regexp"
+ "time"
+)
+
+var NoTrailingOrLeadingSpaceRegex = regexp.MustCompile(`^\S(\s*\S)*$`)
type Song struct {
- ID int64
- Title string
- Lyrics string
- Date time.Time
- Artists []SongArtist
+ ID int64
+ Title string
+ Lyrics string
+ Date time.Time
+ Artists []SongArtist
+ AlbumIDs []int64
+ Genres []Genre
}
diff --git a/internal/server/htmx/htmx.go b/internal/server/htmx/htmx.go
@@ -0,0 +1,24 @@
+package htmx
+
+import (
+ "lyrics/internal/server/toast"
+ "net/http"
+)
+
+func HxError(w http.ResponseWriter, status int, msg string) {
+ toast := toast.Error(msg)
+ toastJSON, err := toast.Marshal()
+ if err != nil {
+ toastJSON = []byte(`{"HXToast":{"type":"error","body":"An error occurred"}}`)
+ }
+
+ w.Header().Set("HX-Trigger", string(toastJSON))
+ w.Header().Set("HX-Reswap", "none")
+ w.WriteHeader(status)
+ w.Write([]byte(msg))
+}
+
+func HxRedirect(w http.ResponseWriter, location string) {
+ w.Header().Set("HX-Redirect", location)
+ w.WriteHeader(http.StatusOK)
+}
diff --git a/internal/server/new-song.go b/internal/server/new-song.go
@@ -0,0 +1,135 @@
+package server
+
+import (
+ "fmt"
+ "lyrics/internal/domain"
+ "lyrics/internal/server/htmx"
+ newsong "lyrics/internal/server/ui/views/new-song"
+ "net/http"
+ "time"
+)
+
+type NewSongForm struct {
+ Artist []string `schema:"artist"`
+ Title string `schema:"title"`
+ Albums []string `schema:"albums"`
+ Genres []string `schema:"genres"`
+ Lyrics string `schema:"lyrics"`
+ Featuring []string `schema:"featuring"`
+ ReleaseDate string `schema:"release-date"`
+}
+
+func parseNewSongForm(form NewSongForm) (domain.Song, error) {
+ var song domain.Song
+
+ // artists
+ if len(form.Artist) == 0 {
+ return song, fmt.Errorf("At least one artist is required")
+ }
+ for _, a := range form.Artist {
+ if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(a) {
+ return song, fmt.Errorf("Artist name must not be empty or have leading/trailing whitespace")
+ }
+ }
+
+ if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Title) {
+ return song, fmt.Errorf("Title must not be empty or have leading/trailing whitespace")
+ }
+
+ if form.Lyrics == "" {
+ return song, fmt.Errorf("Lyrics must not be empty")
+ }
+
+ // genres
+ if len(form.Genres) == 0 {
+ return song, fmt.Errorf("At least one genre is required")
+ }
+ genres := make([]domain.Genre, 0, len(form.Genres))
+ for _, g := range form.Genres {
+ genre := domain.Genre(g)
+ if _, ok := domain.ValidGenres[genre]; !ok {
+ return song, fmt.Errorf("Unknown genre: %q", g)
+ }
+ genres = append(genres, genre)
+ }
+
+ // albums
+ var albumIDs []int64
+ for _, a := range form.Albums {
+ if a == "" {
+ continue
+ }
+ if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(a) {
+ return song, fmt.Errorf("Album name must not be empty or have leading/trailing whitespace")
+ }
+ // TODO: look up or create album
+ }
+
+ // featuring
+ for _, f := range form.Featuring {
+ if f == "" {
+ continue
+ }
+ if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(f) {
+ return song, fmt.Errorf("Featured artist name must not be empty or have leading/trailing whitespace")
+ }
+ // TODO: look up or create featured artist
+ }
+
+ // release date
+ releaseDate, err := time.Parse("2006-01-02", form.ReleaseDate)
+ if err != nil {
+ return song, fmt.Errorf("Invalid release date: %w", err)
+ }
+ minDate := time.Date(1000, 1, 1, 0, 0, 0, 0, time.UTC)
+ maxDate := time.Now().AddDate(3, 0, 0)
+ if releaseDate.Before(minDate) || releaseDate.After(maxDate) {
+ return song, fmt.Errorf("Release date must be between %s and %s",
+ minDate.Format("2006-01-02"), maxDate.Format("2006-01-02"))
+ }
+
+ song = domain.Song{
+ Title: form.Title,
+ Lyrics: form.Lyrics,
+ Date: releaseDate,
+ Genres: genres,
+ AlbumIDs: albumIDs,
+ }
+
+ return song, nil
+}
+
+func (ss *ServerState) NewSong(w http.ResponseWriter, r *http.Request) {
+ logger := ss.logger.With("handler", "NewSong")
+
+ switch r.Method {
+ case http.MethodGet:
+ newsong.NewSong(newsong.NewSongParams{}).Render(r.Context(), w)
+ case http.MethodPost:
+ err := r.ParseForm()
+ if err != nil {
+ logger.Error("failed to parse http form", "err", err)
+ htmx.HxError(w, http.StatusBadRequest, "Failed to add new song.")
+ return
+ }
+
+ var newSongForm NewSongForm
+ err = decoder.Decode(&newSongForm, r.PostForm)
+ if err != nil {
+ logger.Error("failed to decode http form", "err", err)
+ htmx.HxError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ newSong, err := parseNewSongForm(newSongForm)
+ if err != nil {
+ logger.Error("failed to parse new song form", "err", err)
+ htmx.HxError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ logger.Debug("added new song", "song", newSong)
+
+ htmx.HxRedirect(w, "/")
+ }
+}
diff --git a/internal/server/router.go b/internal/server/router.go
@@ -4,13 +4,20 @@ import (
"net/http"
"github.com/go-chi/chi/v5"
+ "github.com/gorilla/schema"
)
+var decoder = schema.NewDecoder()
+
func (ss *ServerState) Router() http.Handler {
router := chi.NewRouter()
router.Get("/", ss.Index)
+ router.Get("/new", ss.NewSong)
+ router.Post("/new", ss.NewSong)
+
router.Handle("/static/*", ss.Static())
+
return router
}
diff --git a/internal/server/ui/layouts/base/base.templ b/internal/server/ui/layouts/base/base.templ
@@ -6,15 +6,18 @@ templ Base(params BaseParams) {
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+ <script src="/static/js/htmx.min.js" defer></script>
+ <script src="/static/js/alpine.min.js" defer></script>
<script src="/static/js/toast.js" defer></script>
+ <link rel="stylesheet" href="/static/styles.css" type="text/css"/>
<link rel="stylesheet" href="/static/toast.css" type="text/css"/>
<title>{ params.Title }</title>
</head>
<body>
<main>
+ <section id="toast-container"></section>
{ children... }
</main>
- <section id="toast-container"></section>
</body>
<script>
document.body.addEventListener("HXToast", (evt) => {
diff --git a/internal/server/ui/layouts/base/base_templ.go b/internal/server/ui/layouts/base/base_templ.go
@@ -1,61 +0,0 @@
-// Code generated by templ - DO NOT EDIT.
-
-// templ: version: v0.3.1001
-package base
-
-//lint:file-ignore SA4006 This context is only used if a nested component is present.
-
-import "github.com/a-h/templ"
-import templruntime "github.com/a-h/templ/runtime"
-
-func Base(params BaseParams) templ.Component {
- return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
- templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
- if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
- return templ_7745c5c3_CtxErr
- }
- templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
- if !templ_7745c5c3_IsBuffer {
- defer func() {
- templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
- if templ_7745c5c3_Err == nil {
- templ_7745c5c3_Err = templ_7745c5c3_BufErr
- }
- }()
- }
- ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var1 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var1 == nil {
- templ_7745c5c3_Var1 = templ.NopComponent
- }
- ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var2 string
- templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(params.Title)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/server/ui/layouts/base/base.templ`, Line: 9, Col: 24}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - shlf.space</title><link rel=\"preload\" href=\"/static/fonts/epilogue.woff2\" as=\"font\" type=\"font/woff2\" crossorigin><link rel=\"preload\" href=\"/static/fonts/caveat.woff2\" as=\"font\" type=\"font/woff2\" crossorigin><script src=\"/static/js/htmx.min.js\" defer></script><script src=\"/static/js/lucide.min.js\"></script><script src=\"/static/js/alpinejs.min.js\" defer></script><link rel=\"stylesheet\" href=\"/static/style.css\" type=\"text/css\"></head><body><main>")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main></body><script type=\"module\" defer>\n lucide.createIcons();\n </script></html>")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- return nil
- })
-}
-
-var _ = templruntime.GeneratedTemplate
diff --git a/internal/server/ui/styles.css b/internal/server/ui/styles.css
diff --git a/internal/server/ui/views/index/index_templ.go b/internal/server/ui/views/index/index_templ.go
@@ -1,60 +0,0 @@
-// Code generated by templ - DO NOT EDIT.
-
-// templ: version: v0.3.1001
-package index
-
-//lint:file-ignore SA4006 This context is only used if a nested component is present.
-
-import "github.com/a-h/templ"
-import templruntime "github.com/a-h/templ/runtime"
-
-import "lyrics/internal/server/ui/layouts/base"
-
-func Index(params IndexParams) templ.Component {
- return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
- templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
- if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
- return templ_7745c5c3_CtxErr
- }
- templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
- if !templ_7745c5c3_IsBuffer {
- defer func() {
- templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
- if templ_7745c5c3_Err == nil {
- templ_7745c5c3_Err = templ_7745c5c3_BufErr
- }
- }()
- }
- ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var1 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var1 == nil {
- templ_7745c5c3_Var1 = templ.NopComponent
- }
- ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
- templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
- templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
- if !templ_7745c5c3_IsBuffer {
- defer func() {
- templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
- if templ_7745c5c3_Err == nil {
- templ_7745c5c3_Err = templ_7745c5c3_BufErr
- }
- }()
- }
- ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<span>hi</span>")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- return nil
- })
- templ_7745c5c3_Err = base.Base(base.BaseParams{Title: "home"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- return nil
- })
-}
-
-var _ = templruntime.GeneratedTemplate
diff --git a/internal/server/ui/views/new-song/new-song.go b/internal/server/ui/views/new-song/new-song.go
@@ -0,0 +1,3 @@
+package newsong
+
+type NewSongParams struct{}
diff --git a/internal/server/ui/views/new-song/new-song.templ b/internal/server/ui/views/new-song/new-song.templ
@@ -0,0 +1,138 @@
+package newsong
+
+import (
+ "fmt"
+ "lyrics/internal/domain"
+ "lyrics/internal/server/ui/layouts/base"
+ "time"
+)
+
+templ NewSong(params NewSongParams) {
+ @base.Base(base.BaseParams{Title: "new song"}) {
+ <h1>Add a song</h1>
+ <form
+ hx-post="/new"
+ hx-swap="none"
+ hx-disable-elt="#add-song-btn"
+ >
+ <fieldset x-data="{ items: [{ id: 0 }], uid: 1 }">
+ <legend>
+ artists
+ <button type="button" @click="items.push({ id: uid++ })">+ add</button>
+ </legend>
+ <ol>
+ <template x-for="(item, i) in items" :key="item.id">
+ <li>
+ <label>
+ <span>artist</span>
+ <input
+ type="text"
+ name="artist"
+ :required="i === 0"
+ pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() }
+ />
+ </label>
+ <button
+ type="button"
+ aria-label="Remove"
+ x-show="items.length > 1"
+ @click="items.splice(i, 1)"
+ >×</button>
+ </li>
+ </template>
+ </ol>
+ </fieldset>
+ <label>
+ title:
+ <input
+ name="title"
+ type="text"
+ pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() }
+ required
+ />
+ </label>
+ <fieldset x-data="{ items: [{ id: 0 }], uid: 1 }">
+ <legend>
+ albums
+ <button type="button" @click="items.push({ id: uid++ })">+ add</button>
+ </legend>
+ <ol>
+ <template x-for="(item, i) in items" :key="item.id">
+ <li>
+ <label>
+ <span>album</span>
+ <input
+ type="text"
+ name="albums"
+ pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() }
+ />
+ </label>
+ <button
+ type="button"
+ aria-label="Remove"
+ x-show="items.length > 1"
+ @click="items.splice(i, 1)"
+ >×</button>
+ </li>
+ </template>
+ </ol>
+ </fieldset>
+ <fieldset>
+ <legend>genres</legend>
+ for _, genre := range domain.Genres {
+ <label>
+ <input type="checkbox" name="genres" value={ string(genre) }/>
+ <span>{ string(genre) }</span>
+ </label>
+ }
+ </fieldset>
+ <label>
+ lyrics:
+ <textarea
+ name="lyrics"
+ rows="20"
+ spellcheck
+ ></textarea>
+ </label>
+ <fieldset x-data="{ items: [{ id: 0 }], uid: 1 }">
+ <legend>
+ featuring
+ <button type="button" @click="items.push({ id: uid++ })">+ add</button>
+ </legend>
+ <ol>
+ <template x-for="(item, i) in items" :key="item.id">
+ <li>
+ <label>
+ <span>artist</span>
+ <input
+ type="text"
+ name="featuring"
+ pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() }
+ />
+ </label>
+ <button
+ type="button"
+ aria-label="Remove"
+ x-show="items.length > 1"
+ @click="items.splice(i, 1)"
+ >×</button>
+ </li>
+ </template>
+ </ol>
+ </fieldset>
+ <label>
+ release date:
+ <input
+ name="release-date"
+ type="date"
+ min="1000-01-01"
+ max={ fmt.Sprintf("%s", time.Now().AddDate(3, 0, 0).Format("2006-01-02")) }
+ required
+ />
+ </label>
+ <button id="add-song-btn" type="submit">
+ add song
+ </button>
+ </form>
+ }
+}
diff --git a/setup.sh b/setup.sh
@@ -3,3 +3,4 @@
mkdir -p static/files/js
curl -sLo static/files/js/htmx.min.js https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js
+curl -sLo static/files/js/alpine.min.js https://cdn.jsdelivr.net/npm/alpinejs@3.15.11/dist/cdn.min.js